尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A CRM that small service businesses can put their own name and colors on. Each business gets its own workspace ("organization") with its own contacts, products, quotes, contracts and branding — nobody else can see their data.
Just want it on the internet? See DEPLOY.md — a click-by-click guide that needs no terminal.
To run it on your own machine you need Node.js 20+ and a PostgreSQL database:
npm install
cp .env.example .env # fill in DATABASE_URL and AUTH_SECRET
npx prisma migrate dev # creates the tables
npm run dev
Open http://localhost:3000, click "Create your workspace," and sign up. Signing up creates a new organization, makes you its owner, and seeds your two starter contract templates.
Contacts — a person: name, title, company (picked from or added to the Companies list as you type), email, phone, website, birthday, city and state. The list shows the company, the company's Industry and Company Type, a notes counter and a counter for each activity type (text, email, phone call, meeting). The contact page is the hub: log activity, add notes, open deals, and see every quote and contract for that customer. Notes take an optional label (General, Personal, Birthday, Hobbies, Family); the Personal switch on the notes feed gathers the personal ones. + Include multiple contacts on the note and activity forms logs the same entry on several people at once (it searches as you type). The Activity overview box on the right counts every touch, shows the last one, the open pipeline and the quotes out.
Companies — a business you sell to, with the people who work there.
Name, phone, email, website, city, state, status, Industry and
Company Type. A company page rolls up notes, activity, deals, quotes
and contracts from its people (each entry names the person; the People
list pages at fifty and every feed shows its latest 200) and takes notes
and activity of its own. A residential customer is a contact with no
company and reads as Individual / Personal; nothing downstream
requires a company. Typing a company name on a contact creates the
company. Fill in missing on the Companies list (Sept 14, 2026) tags
every untagged company and copies a phone or website up from its people
where those are blank, in batches with a progress bar; anything it fills
wears a small auto mark (with an Auto-filled toggle in the filter
bar to review them) until you edit that field or press Looks right on
the company page. Rules in src/lib/enrich.ts; only the spreadsheet and
the company's own people are used, nothing outside the workspace.
Industry and Company Type (Sept 13, 2026) — two multi-select pick
lists on the company, shown and edited on the contact form too (they
belong to the company, so a change on a contact changes the company for
everyone there). Every workspace starts with six industries: MDU and
Student (Owner, Capital Group, Developer, Property Management),
Commercial, Construction and Small Business (General), and Service
Provider (Integrator, Electrician, Networks/ISP, Access Control, Door
Hardware, Gates). + Add new industry and + Add new company type
on either form grow the lists for that workspace; a new industry starts
with General. The lists live in IndustryOption / CompanyTypeOption,
seeded the first time a workspace opens a form (src/lib/industries.ts),
and a company stores the names, like a contract stores its type.
Lists at scale (Sept 13, 2026) — Contacts and Companies show 50 rows a page (10 / 50 / 100 to pick from) with Showing 51–100 of 12,340 and page links; the search bar and every filter run in the database and live in the address bar, so Back and bookmarks keep the view. Filters: State, Industry, Company Type (all multi-select, they stack), plus Company on contacts (type to search), Status (Oct 4, 2026: any steps of the status ladder, Not Actioned through Archived, in ladder order; hidden on the Interested sub-panes, which hold it already), and the toggles Favorites, With deals and Needs attention (contacts with no email and no phone, or a company nobody has tagged; companies with no industry, or no phone and no email). Active filters show as chips with Clear all. The search box treats % and _ as plain text, and a filter value may contain a comma. Every type-to-search picker (company on a contact,
pg_trgm) on names, emails and phones and GIN indexes on the tag lists
keep search and filters fast at 200,000 rows;
scripts/browser-tests/load-test.cjs proves it.Owes you (Sept 14, 2026) — under the name on every Companies and Contacts row: Owes you $1,800.00 · 1 overdue · next due Dec 15. It is what they signed for on Money-in paperwork less the payments recorded against it, so it needs no invoice typed; a purchase order to a supplier is money going the other way and never counts. Nothing shows at zero. Owes money in the filter bar keeps only the people who owe. A homeowner has no company, so their balance sits on their own contact row. Company and contact pages carry a Balance card — Owes you, You owe them, next due, and every open payment row with what is left on it — and the dashboard opens with Owed to you across the whole workspace, which links to the list already filtered. The figure is worked out in one database query per page, so it stays fast on a long list.
Favorites (Sept 13, 2026) — the star on a contact or company row and page. Favorite Contacts and Favorite Companies in the sidebar are the list with the star held on; Contacts with Deals and Companies with Deals are the same for anyone a deal or quote carries.
Import CSV (Sept 13, 2026) — on both lists. One spreadsheet of any
size: the browser reads it, shows what it found (rows, companies, columns
matched, warnings), then sends 500 rows at a time with a progress bar
and a running count of contacts and companies added or updated. Only
Name or Company Name is required; First name + Last name are joined;
Title, Email, Phone, Website, City, State (Texas → TX), Birthday, Status,
the company's phone / email / website / city / state / status, Industry
and Company Type are picked up when present, with loose header matching
("E-mail", "Cell", "DOB", "Job Title", "Account"). A company that already
exists (name, ignoring case) is reused and only its blank details are
filled; a bare company name is enough to create one, and a row with no
person applies its Phone, Email, Website, City, State and Status to the
company. A company type with no industry on the row is filed under the
industry it already lives in ("Integrator" lands on Service Provider) or
under "Uncategorized". A contact whose email already exists is updated
(name included), else matched on name + company, else created.
Re-uploading the same file updates instead of doubling. Windows Excel's
non-UTF-8 CSVs are decoded correctly. Every company a batch touches that
still has no Industry and no Company Type is tagged from its name, its
website's domain, its people's work-email domain and their titles
("Acme Electric" → Service Provider / Electrician, "Lakeside Towers" →
MDU / Property Management, anything unplaceable → Uncategorized /
General), and a blank company phone or website is copied up from its
people when one person is clearly the main line or everyone agrees
(Sept 14, 2026; src/lib/enrich.ts). Anything filled in that way wears
a small auto mark; a later file that carries the value replaces the
guess, and the running count reads "Tagged 12 · 5 phones · 8 websites
filled in". Stop
after this batch halts it; a skipped-rows CSV lists anything that did
not import and why. Download the template gives the exact headers,
contact fields first, then the company's
(/dashboard/contacts/import-template).
Pipeline — deals by stage (Lead → Contacted → Quote Sent → Contract Sent → Won / Lost) with per-stage value totals. A deal is one job you are trying to win. Every quote belongs to a deal (a deal can carry several, say a per-hour and a per-day labor option), a contract may. Sending a quote or a contract moves the deal forward on its own, a signed contract wins it, and a deal's value is read from its quotes — the accepted one, else the latest one the customer hasn't declined. The typed estimate only counts until a quote exists.
Back links follow the rule how I got there is how I go back: the
link at the top of a record points to the page you came from (a quote
opened from a contact goes back to that contact; the same quote opened
from the Quotes list goes back to the list). The trail lives in the
browser tab (src/lib/trail.ts); form pages are skipped, so creating a
quote from a contact still lands back on the contact.
Products — the catalog you quote from. Each product carries a
description, SKU, default tag, OEM / manufacturer, COGS (cost, shown on the
owner's list but never to customers or partners), unit price and a unit of
measurement. The unit list follows the default tag — a Labor product picks
from the Labor list, Materials from Materials, Software from Software;
Project Services, Shipping and Taxes carry no unit — and a software unit
adds a rate and term with the total worked out on screen. A product can
name its distributor and the people there — name, email and phone — picked
from per-workspace lists that grow from the form itself; every supplier
picker also lists the approved Distributor, Hardware / OEM and Software
Provider accounts on Software Connect Brands, and picking one links your
supplier record to that account (Oct 25, 2026). The list page can
clone, delete, and flip a product between active and inactive in place.
Since Oct 25, 2026 it pages at 10 / 50 / 100 with a search box (every
word matched against name, part number, description and manufacturer)
and stacking Supplier, Manufacturer, Tag, Service type and Status filters,
like Contacts; the quote's Add from catalog box and the Create
Ratesheet picker search the catalog instead of loading all of it, so a
distributor's full catalog of tens of thousands of products opens as
fast as five (proven at 50,000 by products-load-test.cjs).
Link Ratesheet → Import Ratesheet (Oct 21, 2026) brings in a
distributor's, manufacturer's or software provider's price list. The
price on their sheet is your cost (COGS) and their MSRP is kept
beside it on the product; your sell price comes from a markup, a margin,
or their MSRP less a percentage — never from the sheet on its own. Excel
(every tab; a Discontinued or Read Me tab starts unticked) or CSV
(including Windows Excel's), the header row found under title rows,
columns read by their names and by what the cells hold, each fixable
from a dropdown and remembered per supplier. Who sent it? is
required (+ Add New Distributor makes the Company too) with an optional
point of contact. Every product is shown as it will land with a tick box
(Select all / Remove all) and a sell price you can type over; only
ticked rows import, in batches. A product you already carry keeps its
own name, description, tag and unit: cost up keeps your margin, cost
down keeps your price, a cost that more than doubles or halves waits for
a tick, and "Call", "N/A" or $0 never wipes a cost. Decline asks
what read wrong and sends the file and the note to the operator console
(/admin → Ratesheet imports to look at). The file is saved under
Ratesheets with what the import did, and a file uploaded there earlier
gets Import prices. The product form links COGS, Markup %, Margin %
and Unit price and has an MSRP box; the Products list shows margin with
markup under it.
Ratesheets — a sub-module under Products. A ratesheet is a named
selection of your products published for partners: Public,
Invite/Approve, or Partner Specific (sent to one email address),
with an Expires On date and an Approved/Decline Within window. Each
send produces a link (/r/<token>) where the partner sees the prices —
never the cost, SKU, manufacturer or distributor — and approves or
declines. The landing page shows a summary of every send with its status
on the left and the ratesheet tiles (approved/sent, expires on) on the
right; clicking a tile filters the summary. + Link Ratesheet uploads a
distributor's own price list (any file up to 4 MB, stored in the
database) so it can be downloaded from the same page, or imports it (see
Products); linking another workspace's published sheet is marked Coming
Soon.
Quotes — created against a customer and a deal (type to search the customer's deals, or + Add new deal), with two templates, Simple (clean and printable) and Modern (branded dark layout). Every line item has the product name, a product description sub-note, a project specific notes sub-note, quantity, unit value, line total, a discount (% or $) and a required tag. Under the table are Subtotal, a Discount on the whole quote box (% or $, Sept 27, 2026) and the Quote total, and under that a grid totalling every tag — labor, materials, software, project services, shipping and taxes — which adds up to the subtotal, before the discount on the whole quote. Marking a quote sent produces a public link the customer can open without an account.
Contracts — reusable templates, each with a name, a type from a per-workspace pick list (Service Agreement, Change Order, Purchase Order, Sales Order, Invoice, Compliance, Custom to start; "+ Add new type" adds an mNDA or a Commission Agreement in place), a description, and "Who can send?" (everyone, or a searchable pick of users). Six templates come preloaded: Service Agreement, Change Order, Purchase Order, Sales Order, Invoice and Compliance Agreement. Merge fields are chips named in plain words — Contact Name, Company Name, Deal Name, Quote Total, Contract Total, Payment Schedule, Your Address — grouped by the screen they come from (ALL, Contacts, Companies, Pipeline, Products, Quotes, Contracts, Settings); clicking one drops it into the body. Every template page and contract page is two columns: the agreement on the left, a silver line, and Customer Information on the right, where you pick the customer, deal and quote and generate the contract. Preview flips each chip to the real value for that customer, or a flashing "Missing Information" when there is nothing to fill it with. Generating resolves the fields once, so the customer reads and e-signs a frozen copy; the send button honours the template's sender list; signed contracts are locked from editing.
Contract Coordinator (under Pipeline and under Contracts — one page, two addresses) — turns a deal's quote into the contracts it actually needs. Pick a deal and the quote's rows run across the top of the page, priced and editable: change a quantity, unit price or line discount (as a % or in dollars) right there and it saves back to the quote the moment you leave the box, so the quote page and the customer's copy say the same thing (a contract already made from a row keeps the price it was made with). Under the rows a footer reads Subtotal, a Discount on the whole quote box (% or $) and Quote total; the box saves to the quote the same way (Sept 27, 2026). Each row has a tick box under every contract (up to five) and each contract is a card below the rows (Sept 20, 2026 layout — they used to hang off the right edge): who it goes to (company and contact, existing or typed in new — Contract A is prefilled from the deal), which template, which way the money goes, payment terms, an optional title, the rows ticked onto it, a discount on the whole contract (% or $, on top of any line discounts, with Subtotal / Discount / Total shown live; a money-in card starts with the quote's own discount, marked from the quote, until you type in its box, and a purchase order starts with none) and its payment schedule — a real table where every row has its own label, type (% of total, a fixed amount, or the balance), value and due date, with quick fills that write rows you can then edit — the same three names as the Preset Payment Table in Settings: One payment, Deposit + balance (the deposit as a % or $, its own due date, an optional balance date), Installments (how many, every week / 2 weeks / month / year, from a first date) — plus From the quote when the quote carries a payment table. A hand edit turns the quick fill to Custom. Money in is the customer paying you (a Sales Order, a Change Order); Money out is you paying a supplier (a Purchase Order). It follows the template until you set it yourself, and it decides what counts as owed to you: only a Money-in signature wins the deal, and a supplier accepting a purchase order changes nothing on the pipeline. A row can sit on several contracts — the same materials line on the customer's Sales Order and the supplier's Purchase Order — and an unticked row stays Open on the deal; rows can be cancelled and restored. "Your Company Signer" is prefilled with the owner. Creating writes one contract per card with its own line items (each carrying its line discount), its discount, its total and the payment rows exactly as written, and the rows then read CON-1004 · Sent / Signed. Below, every contract on the deal shows sent and signed dates, total, paid-of-total and next due, reminders sent (a reminder is copied to the clipboard with the signing link and logged — no email is sent yet), with Edit, Cancel (frees the rows) and Reopen. is what customers still owe on the deal, with the part they have signed for underneath it; purchase orders are money going the other way and are not in either number. Above the rows a card says in plain words whether the deal is a job yet: PRJ-n with a link once it is, the Sales Order already out for signature when there is one (the job starts when it is signed, or with Mark signed), or the way to award it on a handshake (below).
Discounts (Sept 20 and 27, 2026) live in three places and only
three. A line discount belongs to the quote line — typed on the
quote's line editor (the Discount column, % or $) or inline on the
Contract Coordinator — and is copied onto every contract row made from
that line, the way the price is. A quote discount (Sept 27, 2026)
belongs to the whole quote: typed under the lines on the quote page or in
the Contract Coordinator's footer, % or $, it comes off the lines'
subtotal after their own discounts, and it is what the quote page, the
customer's copy, the quotes list, the pipeline's deal value, the
dashboard and {{quote_total}} all read; the quote's payment table is
priced against the total after it. A contract discount belongs to one
contract, typed on its card (or on the handshake award), and is never
written back to the quote: a quote that splits into a customer's Sales
Order and a supplier's Purchase Order can carry a different discount on
each. A money-in contract starts with the quote's discount — a percent
as the same percent, dollars as its rows' share of them — until someone
types in its box, and a purchase order starts with none, because the
discount was given to the customer, not by the supplier. Every discount
is worked out on the server against the real subtotal, capped so nothing
totals below zero, and the percent it was typed as is kept so a later
change to the lines re-prices it (a dollar discount stays as typed and is
capped on the way out, so it is worth the full amount again once the
lines grow back). The quote's page and customer copy print each line's
saving and then Items / Line discounts / Subtotal / Discount / Total,
each row only when there is something on it; a contract's page and the
customer's copy print the line discounts and the contract discount the
same way; {{contract_total}} merges the discounted figure; a job's
Awarded is the discounted contract total, shared across its scopes in
proportion to their rows.
A contract's page shows its line items with Subtotal / Discount / Total when there is a discount, a payment table that recalculates (percent, fixed or balance rows; presets; final payment date), and the signer; the customer's copy prints the Items and Payment Schedule tables under the agreement, with the recipient's logo.
Payments (Sept 14, 2026) — Record payment on any row of a payment table takes an amount, a date, how it was paid and a reference, and partial payments are normal: a row reads Paid $600.00 of $1,200.00 until it is covered. Ticking Paid records one payment for whatever is still open; unticking takes those payments back off. A row can never take more than it is owed, and an amount can't be edited below what has already been recorded on it. A signed contract's payment table stays editable — dates, amounts and percents can all be amended after the job is awarded — and the rows keep their identity, so the payments on them stay attached. Mark signed on a sent contract records one that was signed on paper or agreed some other way: who signed, when, and a note; the contract then reads Signed offline. Deleting a contact, company or contract that has payments recorded or money still owed is refused, with the amounts, and offers to archive instead.
Projects (Sept 14, 2026) — a won job. The moment a customer signs an agreement, the app makes one: PRJ-1000, with the customer, the date and a budget already in it. Nothing is typed. A supplier accepting a purchase order does not make a job, because that is a cost, not a win. For a job won on a handshake, Award without paperwork on the Job card of the Contract Coordinator (Sept 20, 2026: a plain button on a card of its own, no longer a link hidden in a stat tile) opens a full form: who agreed and when, a note, which of the quote's open rows they agreed to (all of them by default), a discount on the whole job (started from the quote's own discount, marked from the quote), payment terms and the same editable payment schedule the contract cards use, with the awarded total shown live. Awarding writes the Sales Order those rows imply, marked signed offline, and starts the job. An agreement signed before jobs were tracked gets a Track this as a job button on its page.
The budget is four numbers. Awarded is what the customer agreed to, from the signed agreement and any change since. Spent is what has actually been paid out. Committed is purchase orders that are out but not yet paid, so a draft order is still just a plan. Left is awarded less spent and committed; the bar turns amber past 85% and reads Over by $500 when it goes through. Alongside them: Billed, Collected and Still owed to you, and an owner-only Expected cost on file from what the products cost you, which never appears on anything a customer can open.
Scopes of work. Turn on Split by service type on a quote and each row can say what kind of work it is — Smart Locks, Access Control, Painting — from a per-workspace list that starts with the trades and the connected-device work and grows with "+ Add new service type". A product can carry one too, so a quote line picks it up. When the job is awarded, each kind of work becomes a scope with its own budget, its own crew and its own rows, and together they always add up to the job's: move a row from one scope to another and two bars change while the job's total does not. A job with one scope shows a single bar and never mentions the word. Under each bar is the award history: every signed agreement, change and typed adjustment, dated, with the reason, so the number above it can always be explained. Changing it by hand asks why.
The Projects list searches names, customers and PRJ numbers, filters
by stage and service type, and hides finished and cancelled jobs until
you ask for them. Stages are Awarded → Active → Completed, plus
Delayed and Cancelled. Projects → Budgets puts every live job's
bar on one page, tightest first. A customer's page lists their jobs, and
the dashboard opens with Active jobs. npm run recompute-projects
re-derives every stored number from the paperwork and reports any drift;
it should always say there is none.
The Money tab on a job (Sept 14, 2026) — three strips across a job: Budget, Money, Files. Money answers the two questions a contractor asks daily.
Who owes me. Every payment row on the agreements the customer signed,
with what is left on each. Send as invoice stamps it INV-1000 and
mints a link the customer opens at /i/<token>: the amount due, when it
is due, what it covers, the payments already received, and how to pay you
from Settings → General → How customers pay you. It downloads as a
PDF. Sending the same row twice hands back the same invoice number rather
than a second one. Recording what came in is done right on the job —
amount, date, method, a reference — and the customer's copy of the
invoice updates with it. The app never emails the invoice; there is a link
to copy, and the screen says so.
Who I owe. Materials on the job whose product carries a distributor can be turned into that supplier's purchase order at what they cost you, in one tap — the cost captured when the contract was made, or the product's catalog cost today when that was $0 (the old Import CSV left $0 on everything it brought in) — and the supplier gets a company record tagged Distributor so it shows up in the CRM like anybody else. Bills are grouped by supplier with what is paid of what was ordered. A draft order counts as Committed, never Spent.
Change orders are raised on the job and land on one scope: more work pushes that bar up, and a credit pulls it down and comes off what the customer owes rather than showing as something to chase. Files on a job hold the other party's own signed contract and anything else the job needs; a job's file needs a login, unlike a logo.
Job photos and videos (Oct 17, 2026; reworked Oct 21, 2026) — the
top of a job's Files tab. Photos belong to the job; what they are is
a tag. Two tag lists per workspace, each pick-many with "+ Add":
Photo Type (General Project, Site Walk, Installation, Finished
Install; a photo with none is General Project) and Location / Area
(twelve starters: Lobby, Office, Bathroom, Kitchen, Hallway, Unit /
Apartment, Clubhouse, Fitness Center, Pool Area, Exterior, Roof, Parking
Garage). Each photo is a square tile with who sent it, when, and its tags;
Tags changes them, chips across the top filter (types and locations
stack). The job's Main folder and its mini folders sit on a bar;
Move takes a photo out of where it is, Clone leaves it and puts a
copy in each folder picked (a copy is its own row on the same stored file:
own tags, own folder, and the file goes with the last copy), both with
"Create Mini Folder". Who can see the — owners and admins
Grant access per folder: the job's accounts (the customer, anyone
with paperwork on it, subcontractor crews that logged time) with a Select
all per account or people ticked one by one, plus Invite Other Users
from the CRM or by email (someone new is added as a contact). View
only looks; Editor also adds photos to that folder and changes tags;
nobody outside deletes, moves or clones. Each person gets their own link
at /p/<token>, emailed from the business if ticked, with Make editor /
Make view only, Turn off, and when they last opened it; granting someone
again changes their level rather than making a second link. Your own team
sees everything. Crew photo link is one link per job for installers:
name (remembered on the phone), optional tags (they can add new ones),
send to the main folder; it never shows anything. Files live in Vercel
Blob (private), up to 1 GB each, sent straight from the phone on a
signed one-path ticket; /files/<token> checks who is asking (a grant
only opens its own folder) and redirects to an address that works for ten
minutes (src/lib/storage.ts, rules in src/lib/job-photos.ts). A laptop
without Blob keeps them in .local-storage/ (STORAGE_DRIVER=local).
Crews and time (Sept 14, 2026) — Projects → Crews is who does the work. A crew is either your own people or a subcontractor, and the difference is entirely about money: your own crew's hours are your cost, so the time you log lands on the job's budget; a subcontractor bills you, so their money arrives as their purchase order and their hours are tracked for the schedule and for checking that bill, never added on top. The screen says so in those words, and the one mistake that can double a cost — a sub's hours counted and their order on the same job — gets called out on the job with the crew named.
A crew carries an hourly rate, a day rate or both, a phone and email, and the service types they cover. People on a crew have their own rate when theirs differs, and the row says which number a logged hour will use. Money is why nobody can be deleted once they have worked: retire a crew instead, and the cost stays on the jobs they worked.
On a job, Crew & time logs a day in two taps. Hours, days, or both on
one entry — three days plus four hours of overtime is one row, because that
is how a week actually goes. Picking the crew moves the scope to the one
they are on. The rate is copied onto the entry as it stands that day, so
raising a rate next month never rewrites what last month cost. Time still
to pay counts as Committed on the budget and paid time counts as Spent, so
the bar is honest before payroll runs; Mark paid through settles
everything up to the day payroll went out, for everyone or for one crew.
npm run recompute-projects checks it all still ties out.
The calendar (Sept 14, 2026) — everything scheduled, in one place. Dates here are days, not moments: an install day is that day wherever you read it from, so times are optional and a day with no clock on it is normal. Month is the six-week grid (always six, so nothing jumps as you page); Week is seven columns you can work from the night before. Filter by crew or by kind of day; both live in the address bar, so the week you are looking at can be sent to somebody. A day can be moved to another date, ticked done, or edited in place.
Event types are a per-workspace pick list — Install, Site walk, Project meeting, Service call, Delivery, Inspection, Punch list — with "+ Add new event type" on the form, and each type gets its own colour so a month reads at a glance. An event carries a crew, a job and which scope of it, a location, notes, and who else is expected: the property manager, the homeowner, the owner's superintendent. Nothing is required — a site walk happens before there is a job, and a coffee with a contact belongs to nothing at all. A contact's and a company's page each carry a Coming up card with + Event, because a walk-through gets booked while you are looking at whoever asked for it; a contact sees days they are only an attendee on, not just their own.
A job's Schedule tab is the one that matters on site. Each scope of work gets Schedule install — two dates and it is booked, on the crew already assigned to that scope, so nobody fills in a form about what kind of day it is. Booking the first install flips the job from Awarded to Active and sets its start date, and deleting days moves the start back to whatever is now first. The tab counts the install days booked, names what is next, and carries Things to do: a thin checklist with an optional day that goes red once the day has gone.
Copy this week into next repeats a crew's week a week on, keeping the weekdays, the times and what each day belongs to — a crew on the same site Monday to Friday for a month is one press a week rather than twenty entries. And when a crew ends up in two places on one day with times that actually collide, the calendar says so. It is a note, not a refusal: sending half a crew to a service call in the morning is a normal day.
Calendar v2 (Sept 27, 2026) — the calendar stops being only days of work and becomes the timeline of everything that happened with a customer and everything still to do. Three ways to lay it out, switched from the toolbar and kept in the address bar: Calendar (the month or week grid, the landing spot), Log (two columns of rows — Previous Activity Log, newest first, and Upcoming Log, soonest first, each grouped by day), and Calendar + Log (the grid on top, the two columns beneath it, on one screen). Previous and Upcoming hang off today whichever month is being paged through above them.
Every day now has an owner — whose calendar it is on. A new day goes on the calendar of whoever made it unless the Whose calendar picker on the form says otherwise; days that were there before belong to the workspace's owner. That is what the company view filters by: Everyone (the default, the whole company's days including any on nobody's calendar), Just me, or a pick of Users from a menu, with Unassigned as one of the choices. Beside it, Companies, Contacts (type to search, and a contact counts whether the day is with them or they are only expected at it) and Projects are multi-select too, and the crew and activity filters stay. Everything stacks: one user plus one company is that user's days with that company. Chips under the toolbar name what is picked; Clear takes it all off.
Activities join the days of work on the per-workspace pick list: Call, Meeting, Email, Text, Quote sent, Quote due, Quote follow up, Contract sent, Contract follow up and Contract closed, each with its own colour, plus + Add new activity on the form. A workspace that existed before gets them the first time its list is read.
The app writes the calendar for you. Logging a call, text, email or meeting from a contact's or company's page puts it on the calendar the same moment — on the day it happened, ticked done, under whoever logged it. The log form gained When (defaults to today) and an optional time: a call remembered on Friday is logged on the Wednesday it happened and lands there; logged on several contacts at once it is one entry with the others expected. A day still ahead is not history yet — it goes on the calendar as something to do and nothing is written to anyone's Activity, and the button reads Schedule instead of Log. Marking a quote sent writes Quote sent today, Quote follow up three days on and Quote due on its valid-until day (which follows the day if it is changed); accepting or declining it, or taking it back to draft, takes the open follow-up and due off and keeps the sent one. Sending a contract writes Contract sent and Contract follow up; the customer signing, or Mark signed, writes Contract closed on the day it was signed and takes the follow-up off, and a cancelled contract loses its follow-up too. Sending the same thing twice moves the milestones rather than doubling them, deleting the quote or contract takes them off with it, and each one links back to its QUO-n or CON-n from the card, the log row and the form. Copy this week into next skips them: a week of work is what gets copied. The Coming up card on a contact, company or job leaves out anything already ticked done, so a quote that went out this morning does not push next month's walk-through off it; the calendar's log still shows both.
Five more, Sept 28, 2026. An Overdue strip sits at the top of Upcoming in red: any call, meeting, email, text, quote due or follow-up whose day has gone by unticked, oldest first, counted in the header. It is still to do, so it is not in Previous either; a day of work (an install, a delivery) happened whether or not anybody ticked it and stays in Previous. Ticking done asks how it went: on any entry with a contact or company on it, Done opens a small box (which kind of touch, what happened) and "Log … and tick it" writes the line into the contact's history on the day the entry was for, one line per person expected; "Just tick it" keeps the old behaviour. Unticking an entry that was logged this way takes the line back out; a call logged from the form keeps its line whatever happens to the tick. The Team view, beside Month and Week, is one row per teammate and per crew across seven days with the week's load on each row ("4 things · 3 of 7 days", or "Free all week"); a cell opens that person's day. The follow-up rhythm lives in Settings → General: "Quote follow-ups" and "Contract follow-ups" as days after sending ("2, 7, 14" is three follow-ups, numbered 1 of 3), applied to the next thing sent. A marketing send is one Email entry on the sender's calendar ("Email · subject", with how many it went to), not one per recipient. And an activity with no clock on it reads No time set rather than All day, and sits at the bottom of its day.
A follow-up after a logged touch (Oct 4, 2026). The moment a call,
text, email or meeting is logged from a contact's or company's page, a
box opens under the form: "Want to set a follow-up with Dana Ruiz?"
Tick what comes next — one or several of Text, Email, Phone Call,
Meeting — and each tick gets its own day (three days on by default, never
before today) and an optional time. Add to the calendar writes one
open entry per tick, titled "Follow up · Dana Ruiz", with the same people
as the touch (the first is who it is with, the rest expected), under
whoever logged it, its notes saying which touch it follows. It shows on
the person's Coming up card and in the calendar's Upcoming log, and Done
there opens "How did it go?" as for any scheduled call. A Meeting ticked
sets Meeting Set, as booking one from the form does. No thanks writes
nothing, and the box opens again for the next touch. Scheduling a call
ahead opens no box: that call is already the thing to do. These are typed
by a person, so the app never re-dates or sweeps them the way it does a
quote's follow-ups. scripts/browser-tests/follow-ups-status-filter.cjs
walks all of it, plus the Status filter.
An activity on the company page is with a person (Oct 4, 2026). The
company page's Log activity still offers the company's people as ticks,
a search for the rest and Add New Contact, and the follow-up box
after. What changed: nobody ticked is refused, with what was typed
kept, unless the box above the list is ticked — "I'm choosing not to
link a contact for these activities." Ticked, the entry lands on the
company alone and reads "Nic Steffl Bypassed" (whoever logged it)
where the person's name would be. Those entries gather under Other
Contacts at the foot of the People card, grouped by who bypassed, each
with Claim (and Claim all on a group): pick one of the company's
people, search for another, or add a new contact on the spot, and the
activity moves onto them — into their history, making them Contacted
(Meeting Set for a meeting), and the calendar entry it made follows.
Entries logged on a company before this rule sit there too, claimable.
A person from another company is never offered. Notes on the company
page are not under this rule, but their tabs now split All · Company ·
People · Personal: Company is what was written on the company itself,
People what was written on its people, each with the person's name.
scripts/browser-tests/company-bypass.cjs walks all of it.
Properties (Sept 14, 2026) — Projects → Properties is a building at a time. A property manager with four towers wants the number per tower, not per job: what was awarded across every job there, what has been spent, what is still owed. The bar is a sum of the jobs' own numbers, so it can never disagree with them, and there is nothing new to keep in sync.
A job belongs to at most one property, which is what keeps the roll-up honest — the same money can never be counted under two buildings. Picking a job on a second property moves it, and the screen says so before you save. Which stages count is chosen on the screen rather than stored: cancelled work is left out by default because it is money nobody ever agreed to pay, and the chips put it back if you want it. That choice lives in the address bar, so a view can be sent to somebody. Jobs at no property are counted up at the bottom of the list, so nothing is quietly missing from every total. Deleting a property leaves every job at it exactly as it was.
Close out (Sept 14, 2026) — a button on the job, not a process. What makes it worth pressing is the list beside it: money still owed, payments never sent as an invoice, an agreement never signed, bills still out, crew time not paid, work never scheduled, things still on the checklist. None of it blocks the button — a contractor closing a job with $500 still owed knows something the app does not. It records the day and a note, and reopening puts the job back to Active while keeping that note as history.
Notes on a job sit beside the budget: what was said on site, kept with
the job rather than with a person, so it outlives whoever said it. And
{{project_number}}, {{project_name}} and {{project_site_address}} are
merge fields under a Projects tab in the template palette, filled in on
the change orders and purchase orders raised against a job.
A bug audit on Sept 14, 2026 (/session-auditor) found and fixed 27
confirmed defects in that day's work. The ones worth knowing about as
behaviour: money on a job is recomputed when a purchase order is sent,
cancelled or deleted, not only when a payment moves; a credit change
order comes off Billed as well as off what is owed, so the Budget tab and
the Money tab agree; a second agreement with no priced rows on a deal
that is already a job adds nothing (it used to add the whole quote
again); -8 or 7,5 typed into Hours is refused by name rather than
read as 8 or 75; an amount past what a money column holds is refused in
words; "Owes money" now means what the figure beside the name says —
a credit that settles a customer takes them off the list, and money on a
contract follows the contract rather than whatever company the contact is
at today; and a refused save hands your typing back instead of emptying
the form.
Settings — two sub-panes. My Account: name, job title, email, mobile, a "Receive in-app messages" switch (stored, not yet acted on), a profile picture, and change password. Company Information with five tiles: General (the account type with Request a change, which the operator approves; address, phone, email, website, About us, Company history — all available as merge fields under the Settings tab), Branding (the original settings page: name, logo — now uploaded as a file or pasted as a URL, one logo shared with General — color and time zone), Company Users (everyone with a login, with title and last login), Compliance (upload W-9, COI, licenses with a type and an expiry that is flagged when it runs out) and Marketing (brochures and materials). Companies and contacts take a logo or photo too, shown on their pages, the tracker, and the documents addressed to them. Uploads live in Postgres (2 MB images, 4 MB documents); images are served by an unguessable link so they load on a customer's copy, documents only to the workspace.
Operator console (/admin) — the view across every workspace, for
whoever runs the product. Signups land in a PENDING state and cannot log
in until they are approved; each row shows the owner's contact details, when
they last logged in, and how much they have in the workspace, with Approve,
Reject, Pause and Delete. Pause locks a workspace without touching its data,
which is the intended answer to a customer who stops paying. Since Oct 25,
2026 each row also shows the account type (Business / Vendor, Distributor,
Hardware / OEM, Software Provider — picked at signup) with a select to
change it; Create Account sets a customer up already approved and
emails the owner a link to set their own password (Resend invite until
they log in); and Account type changes lists workspaces' requests to
change type, with Approve and Decline.
Every row that belongs to a business carries an organizationId, and every
query is scoped to session.organizationId. Writes use updateMany/
deleteMany with the organization in the WHERE clause, so an id guessed
from another tenant matches zero rows rather than updating someone else's
record. src/lib/session.ts has the requireSession() helper every
dashboard page and action calls first.
Operator access is a separate axis: isSuperAdmin on User is a
platform-level flag, not a tenant role, and it is deliberately unreachable
from the app — nothing in any form, action or route writes it, so it can only
be set against the database (npm run promote-admin -- <email>). Both
requireSession() and requireSuperAdmin() re-read the flag and the
workspace status from the database on every request rather than trusting the
JWT, so pausing a workspace or revoking an operator takes effect on sessions
that are already open instead of whenever the token happens to expire.
Public document links (/q/<token>, /c/<token>) are the one exception:
they're unauthenticated by design, keyed on a 24-byte random token, and
only ever render that single document. Drafts aren't public at all — the
link only resolves for the team that owns it until the document is sent.
prisma/schema.prisma data model
src/lib/ prisma client, auth, session, formatting,
quote math, merge fields, seed templates
src/components/ UI kit, icons, quote + contract renderers
src/proxy.ts protects everything under /dashboard
src/app/dashboard/... the app: contacts, companies, deals,
products, quotes, contracts, settings
src/app/dashboard/products/ratesheets
the Ratesheets sub-module
src/lib/price-list.ts Import Ratesheet's planner (columns, matching,
pricing), run in the browser and on the server
src/lib/price-list-file.ts reads a ratesheet's Excel or CSV file
src/lib/product-list*.ts the Products list's filters and its queries
src/app/dashboard/products/search the catalog search behind the pickers
src/app/q/[token] public quote view
src/app/c/[token] public contract view + e-signature
src/app/r/[token] public ratesheet view + partner approve/decline
auth.ts vs auth.config.ts: Next.js runs the /dashboard gatekeeping in
the Edge runtime, which can't load bcrypt or Prisma. auth.config.ts
holds the Edge-safe pieces; auth.ts adds the Credentials provider on top
for use everywhere else.
Money is stored as integer cents everywhere, and line totals are rounded per line then summed as integers — that's what keeps the tag totals adding up to exactly the quote's subtotal (the quote total is that less the discount on the whole quote, which belongs to no one tag).
Role enum (OWNER/ADMIN/MEMBER) gates branding
today, but there's no invite flow.Worth building, deliberately not built yet:
The AI items are direct Anthropic API calls from the app — no gateway or middleware layer, so each feature's cost stays legible on its own.