Skip to content

Job Management

  • Build a complete job management system — clients, jobs, dashboard, and realtime sync

Prerequisite

Complete the Get Started guide first.

A job management app is the kind of tool a service business uses to track clients and work orders. Think plumbing, cleaning, landscaping, consulting, repair services. Any business that sends people out to do work and needs to keep track of who, what, where, and when.

By the end of this example you will have:

  • Client management — full CRUD for the people and businesses you serve
  • Job management — work orders with status tracking, assignments, priorities, pricing, and notes
  • Dashboard — real stats showing active jobs, revenue, upcoming work
  • Realtime — all changes sync instantly across connected browsers

Database Schema and Permissions

Prompt

txt
We are building a job management app on top of this template. Create the
database schema and add the permissions we need.

Database (via Supabase MCP):

Create three tables:

1. `clients` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   name (text, not null), email (text, nullable), phone (text, nullable),
   address (text, nullable), notes (text, nullable), created_by (uuid,
   references profiles(id), not null), created_at (timestamptz, default
   now()), updated_at (timestamptz, default now()).

2. `jobs` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   client_id (uuid, references clients(id) on delete cascade, not null),
   assigned_to (uuid, references profiles(id) on delete set null, nullable),
   title (text, not null), description (text, nullable),
   status (text, check status in ('scheduled', 'in_progress', 'completed',
   'invoiced', 'cancelled'), default 'scheduled', not null),
   priority (text, check priority in ('low', 'normal', 'high', 'urgent'),
   default 'normal', not null),
   scheduled_date (date, nullable), completed_date (date, nullable),
   price (numeric(10,2), nullable),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()), updated_at (timestamptz, default now()).

3. `job_notes` table — id (uuid, default gen_random_uuid(), primary key),
   job_id (uuid, references jobs(id) on delete cascade, not null),
   user_id (uuid, references profiles(id), not null),
   content (text, not null),
   created_at (timestamptz, default now()).

Enable RLS on all three tables. Create policies that use the existing
`is_team_member()` function to scope access. For `clients` and `jobs`,
the policy should check that the row's `team_id` matches a team the user
belongs to. For `job_notes`, join through the `jobs` table to check
team membership.

All policies should allow select, insert, update, and delete for team
members. We will handle finer-grained permission checks (like admin-only
delete) in the server routes, not in RLS.

After creating the tables, add these permissions to `shared/permissions.ts`:

```
"clients.view": ["owner", "admin", "member"],
"clients.create": ["owner", "admin", "member"],
"clients.update": ["owner", "admin"],
"clients.delete": ["owner", "admin"],
"jobs.view": ["owner", "admin", "member"],
"jobs.create": ["owner", "admin", "member"],
"jobs.update": ["owner", "admin", "member"],
"jobs.delete": ["owner", "admin"],
```

Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.

Adding plugins later?

If you plan to add API Keys & External API or AI Chat later, their pages will guide you through adding the required permissions. You don't need to add them now.

Clients

Prompt

txt
Build the client management module — server routes and a full UI page.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/clients` — uses `authUser(event, "clients.view")`.
  Returns all clients for the team, ordered by name asc. Include a count of
  jobs per client by joining the jobs table.

- `POST /api/teams/[teamId]/clients` — uses `authUser(event, "clients.create")`.
  Reads { name, email, phone, address, notes } from the body. Validates that
  name is required. Sets team_id from the auth context and created_by from
  the authenticated user (user.sub).

- `PATCH /api/teams/[teamId]/clients/[clientId]` — uses
  `authUser(event, "clients.update")`. Updates whichever fields are provided
  in the body. Validates that the client belongs to the team before updating.

- `DELETE /api/teams/[teamId]/clients/[clientId]` — uses
  `authUser(event, "clients.delete")`. Validates that the client belongs to
  the team before deleting.

UI:

Create `app/pages/clients/index.vue` — a client list page as a top-level
route (not nested under `/dashboard`), wrapped
in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Clients",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Client"
  button wrapped in `CanAccess permission="clients.create"`.

- The body shows a table of clients with columns: name, email, phone,
  address, and job count. Use `USkeleton` for loading state on initial load.
  Show an empty state with an icon and message when there are no clients.

- "New Client" button opens a `UModal` with a form: name (required), email,
  phone, address, notes. Use Zod for validation. Show loading state on the
  submit button. Show success toast on creation.

- Clicking a client row opens a `USlideover` for editing. Show all fields
  in a form. Include a delete button at the bottom wrapped in
  `CanAccess permission="clients.delete"` — clicking it shows a confirmation
  modal before deleting. Show loading on the save button.

- After creating, editing, or deleting a client, refresh the client list.

Sidebar navigation:

Add a "Clients" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-users-group-two-rounded-bold-duotone`.

The items array needs to become a `computed` (if it isn't already) so we
can conditionally include items based on permissions. Import `useUserRole`
and use `can()` to gate items that require specific permissions — though
for now, all roles can see the Clients link.

Jobs

Prompt

txt
Build the job management module — this is the main feature of the app.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/jobs` — uses `authUser(event, "jobs.view")`.
  Returns all jobs for the team with the client name and assignee name
  joined in. Order by scheduled_date desc, with nulls last. Support
  optional query params: `?status=` to filter by status, `?assigned_to=`
  to filter by assignee UUID.

- `POST /api/teams/[teamId]/jobs` — uses `authUser(event, "jobs.create")`.
  Reads { title, client_id, assigned_to, description, status, priority,
  scheduled_date, price } from the body. Validates that title and client_id
  are required. Sets team_id from auth context and created_by from the
  authenticated user (user.sub).

- `PATCH /api/teams/[teamId]/jobs/[jobId]` — uses
  `authUser(event, "jobs.update")`. Updates whichever fields are provided.
  If status is being changed to "completed", automatically set
  completed_date to today. Validates that the job belongs to the team.

- `DELETE /api/teams/[teamId]/jobs/[jobId]` — uses
  `authUser(event, "jobs.delete")`. Validates that the job belongs to
  the team before deleting.

- `GET /api/teams/[teamId]/jobs/[jobId]/notes` — uses
  `authUser(event, "jobs.view")`. Returns all notes for the job with
  the author's name joined in, ordered by created_at asc.

- `POST /api/teams/[teamId]/jobs/[jobId]/notes` — uses
  `authUser(event, "jobs.view")`. Any team member can add notes. Reads
  { content } from the body. Sets user_id from the authenticated user.

UI:

Create `app/pages/jobs/index.vue` — the main jobs page as a top-level
route (not nested under `/dashboard`), wrapped
in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Jobs",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Job"
  button wrapped in `CanAccess permission="jobs.create"`.

- `UDashboardToolbar` below the navbar: on the left, show status counts
  (e.g. "3 Scheduled, 5 In Progress, 12 Completed"). On the right, status
  filter tabs using a `USelect` or `UTabs` with `size="xs"`:
  All, Scheduled, In Progress, Completed, Invoiced, Cancelled.

- Job list in the body as a table with columns: title, client name,
  assignee name (or "Unassigned"), status badge (color-coded — use
  different colors for each status), priority badge, scheduled date
  (formatted nicely), and price (formatted as currency).

- Status badges should use these colors: scheduled = "info",
  in_progress = "warning", completed = "success", invoiced = "neutral",
  cancelled = "error".

- Priority badges: low = "neutral", normal = "info", high = "warning",
  urgent = "error".

- Each row has an inline status dropdown that lets you change the status
  directly from the table. Use `UDropdownMenu` with the status options.
  Show per-row loading state while the status is being updated.

- "New Job" button opens a `UModal` with a form: title (required),
  client (required — select from existing clients fetched on mount),
  assignee (optional — select from team members fetched on mount),
  status, priority, scheduled date, price, description. Use Zod for
  validation. Show loading on submit.

- For the client select: fetch clients via `GET /api/teams/{teamId}/clients`
  on mount and show in a `USelect` with the client name as the label.

- For the assignee select: fetch team members via
  `GET /api/teams/{teamId}/members` on mount and show in a `USelect`
  with the member name as the label. Use `placeholder="Unassigned"` —
  do NOT use an empty-string value for "Unassigned" in a `USelect`, this
  causes Reka UI's Select component to crash.

- For date fields (scheduled date), use a `UPopover` with a `UCalendar`
  inside — not a native date input. Import `CalendarDate` from
  `@internationalized/date` (install the package if needed). Use
  `useTemplateRef` for the popover reference. Convert the `CalendarDate`
  to an ISO string on submit.

- Clicking a job row opens a `USlideover` for the job detail. Show all
  fields in an editable form at the top. Below the form, show a "Notes"
  section: a scrollable list of notes (author name, content, timestamp)
  with a text input at the bottom to add a new note. Notes are fetched
  when the slideover opens. The note input should have a send button
  that submits on click or Enter.

- Include a delete button at the bottom of the slideover wrapped in
  `CanAccess permission="jobs.delete"` with a confirmation modal.

- After creating, editing, or deleting a job, refresh the job list.

Sidebar navigation:

Add a "Jobs" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Clients" and
"Settings". Use the icon `i-solar-clipboard-list-bold-duotone`.

Dashboard and Realtime

Prompt 1 — Dashboard

txt
Replace the placeholder dashboard with real data and stats.

Server route:

Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:

- `client_count` — total number of clients for the team
- `active_jobs` — count of jobs with status "scheduled" or "in_progress"
- `completed_this_month` — count of jobs completed in the current calendar month
- `revenue_this_month` — sum of prices for jobs with status "completed" or
  "invoiced" where completed_date is in the current calendar month. Return 0
  if no matching jobs.
- `upcoming_jobs` — the next 5 jobs with status "scheduled", ordered by
  scheduled_date asc. Include client name and assignee name joined in.
- `recent_jobs` — the last 5 jobs with status "completed" or "invoiced",
  ordered by completed_date desc. Include client name joined in.

All queries are scoped to the team's team_id.

UI:

Update `app/pages/dashboard/index.vue` to show:

- A row of four stat cards at the top using a grid layout. Each card shows
  an icon, a label, and the value. Cards: "Total Clients" (client_count),
  "Active Jobs" (active_jobs), "Completed This Month" (completed_this_month),
  "Revenue This Month" (revenue_this_month formatted as currency).
  Use `USkeleton` placeholders while loading.

- Below the stats, a two-column layout:
  - Left column: "Upcoming Jobs" — a list of the next 5 scheduled jobs.
    Each item shows the job title, client name, scheduled date, and
    assignee avatar or name. Clicking a job navigates to the jobs page.
    Show an empty state if no upcoming jobs.
  - Right column: "Recently Completed" — a list of the last 5
    completed/invoiced jobs. Each item shows the job title, client name,
    completed date, and price. Show an empty state if none.

Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.

Prompt 2 — Realtime

txt
Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.

Database migration (via Supabase MCP):

Enable realtime publication and full replica identity for the new tables:

```sql
ALTER PUBLICATION supabase_realtime ADD TABLE clients, jobs, job_notes;
ALTER TABLE clients REPLICA IDENTITY FULL;
ALTER TABLE jobs REPLICA IDENTITY FULL;
ALTER TABLE job_notes REPLICA IDENTITY FULL;
```

Composables:

1. Create `app/composables/useRealtimeClients.ts` — takes a `Ref<Client[]>`
   (or whatever the client array type is). Registers on the `clients` table
   via the existing `useRealtime` composable's `onTable` function. Handles
   INSERT (dedup against existing items by id), UPDATE (replace the matching
   item), and DELETE (remove by id). Always assign a new array to `.value`
   — do not mutate in place, or Vue will not re-render.

2. Create `app/composables/useRealtimeJobs.ts` — same pattern but for the
   `jobs` table.

Update `app/composables/useRealtime.ts` to subscribe to `clients`, `jobs`,
and `job_notes` tables in addition to the existing subscriptions.

Integration:

- In `app/pages/clients/index.vue`, call
  `useRealtimeClients(clients)` where `clients` is the ref holding the
  client list.
- In `app/pages/jobs/index.vue`, call `useRealtimeJobs(jobs)`
  where `jobs` is the ref holding the job list.
- The dashboard stats page should refetch stats when any realtime event
  fires on clients or jobs. Use `onTable("clients", refetchStats)` and
  `onTable("jobs", refetchStats)` directly in the dashboard page — no
  separate composable needed.

What You Built

Starting from a template that handled auth, teams, roles, and permissions, you added:

  1. Clients — a full CRUD module for managing the people and businesses you serve
  2. Jobs — work orders with status tracking, assignments, priorities, pricing, and notes
  3. Dashboard — real stats showing active jobs, revenue, and upcoming work
  4. Realtime — all changes sync instantly across connected browsers

Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.

The patterns you learned — how to create tables, write server routes, build UI pages, add permissions, and wire up realtime — apply to any domain. Inventory management, support tickets, invoices, bookings, fleet tracking — the architecture is identical. Only the tables and business logic change.

What's Next

  • API Keys & External API — let external services authenticate and interact with your data
  • AI Chat — a chatbot that queries your database and answers questions in plain English