An order that sits in silence is an order no one trusts. This platform carries a pharmaceutical order from purchase list to doorstep: add items manually or from the quick-order catalogue, place the order, and watch its status move from Ordered to Delivered - with a Telegram message when the order is sent to the supplier. Procurement becomes a visible thread: statuses, dates, and an immutable audit log in the database. The team is told; the record is kept.
| Orders | Workflow | Telegram | History |
|---|---|---|---|
| manual + quick | status workflow | send-time alerts | current + audit |
The procurement loop - order, notify, confirm, audit - is sealed.
Built with Vue 3 + Pinia + Vite, backed by Supabase, deployed to Firebase Hosting, speaking through Telegram.
suradet-ps, artifact keeper
One clone, one install, one command.
⟫ git clone https://github.com/suradet-ps/tracker.git
⟫ cd tracker
⟫ bun install
⟫ bun run dev
Environment
Copy .env.example to .env and fill the credentials:
VITE_SUPABASE_URL="YOUR_SUPABASE_PROJECT_URL"
VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
The Telegram bot token and chat id are Supabase Edge Function secrets,
not Vite variables - set them with
supabase secrets set TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=....
Requires Bun 1.3.1 (pinned via packageManager in package.json), a
Supabase project, and a Telegram bot with its chat id.
Deploying the Telegram edge function
The notifier lives in supabase/functions/send-telegram-notify. It is invoked
when a pharmacist sends an order group from the order screen, not when a draft
is first created. It requires a signed-in pharmacist or admin, loads the order
rows itself, and builds the Telegram message server-side; the client only sends
order ids. It records each supplier group in the telegram_notifications table
so a retry after a timeout cannot send the same Telegram message twice. Apply
supabase-schema.sql first, then deploy with the Supabase CLI (Docker is not
required):
⟫ bunx supabase login
⟫ bunx supabase link --project-ref YOUR_PROJECT_REF
⟫ bunx supabase functions deploy send-telegram-notify
Secrets stay in the Supabase project (TELEGRAM_BOT_TOKEN,
TELEGRAM_CHAT_ID); they are never Vite variables. Firebase Hosting, Vercel
domains, and local development are allowed by default; for a custom domain,
set ALLOWED_ORIGINS to a comma-separated list of browser origins. Local CLI
state such as supabase/.temp/ is ignored by git.
Staff access and roles
Procurement data is invite-only. Access is controlled by public.profiles:
a signed-in user with no profile row (or with role pending) can read
nothing, and only an administrator can change the allowlist.
| Role | Read | Create / update | Delete |
|---|---|---|---|
viewer |
yes | - | - |
pharmacist |
yes | orders, drugs, suppliers, plans | automatic plans |
admin |
yes | yes | all records and profiles |
Deleting procurement records is admin-only; pharmacists can delete automatic
plans because that is the only delete action in the app. The
generate-auto-orders edge function also checks the caller's profile before a
manual run, since it writes with the service role and bypasses RLS.
First deploy / existing project
Run these steps before deploying the app, because the login flow now checks the caller's profile.
- Apply
supabase-schema.sql. It createsprofilesand replaces the blanket policies with role-scoped ones. - Bootstrap the first administrator in the SQL editor, replacing the email:
insert into public.profiles (id, email, role) select id, email, 'admin' from auth.users where email = 'admin@hospital.go.th' on conflict (id) do update set email = excluded.email, role = 'admin', updated_at = now();
- Add each staff member the same way with
'pharmacist'or'viewer'.auth.userslists the accounts; a profile row is what grants access. - Turn off public sign-ups so new accounts cannot be created from the login
endpoint. Either use Dashboard > Authentication > Settings and disable
"Allow new users to sign up" (keep the Email provider enabled), or run
bunx supabase config diffthenbunx supabase config pushto push[auth] enable_signup = falsefromsupabase/config.toml.
Accounts without a staff role see a "waiting for approval" screen. Deleting the profile row (or the auth user) revokes access immediately.
Enabling automatic purchase plans
The plan runner lives in supabase/functions/generate-auto-orders and
creates draft orders in ต้องสั่งซื้อ, then notifies the pharmacy chat.
- Apply the new tables in
supabase-schema.sql(auto_order_plans,auto_order_runs). - Deploy the function:
⟫ bunx supabase functions deploy generate-auto-orders - Set the shared cron secret on the function and store the same value in
Vault:
⟫ bunx supabase secrets set CRON_SECRET=.... - Enable
pg_cron+pg_net, then runsupabase/cron/auto-order-plans.sql. The job runs daily at 08:00 Asia/Bangkok.
A plan stores the historical usage you enter (quantity over a number of
months) and the reorder interval; the runner calculates the draft quantity
from those values. The page also has a dry-run button, so a plan can be
tested without writing orders or sending Telegram. Each plan uses the
calculated quantity by default; tick "กำหนดเอง" in the plan form to store an
explicit order_quantity per cycle (for example, to match a supplier's pack
size). Run supabase-schema.sql before deploying so the column exists.
Enabling overdue reminders
The reminder runner lives in supabase/functions/send-overdue-reminders. It
sends one plain-text follow-up per supplier for orders that have been
สั่งแล้ว longer than OVERDUE_DAYS (default 14). It only reads orders and
sends Telegram; it never changes procurement state.
- Set
CRON_SECRETas a function secret and store the same value in Vault. Optional: setOVERDUE_DAYSandSIGNATURE. - Deploy the function:
⟫ bunx supabase functions deploy send-overdue-reminders - Enable
pg_cron+pg_net, then runsupabase/cron/overdue-reminders.sql. The job runs Monday-Friday at 08:00 Asia/Bangkok.
Deploy: ⟫ bun run build then either ⟫ firebase deploy (the
firebase.json rewrites serve the SPA from dist/) or push to Vercel
(the vercel.json rewrites provide the same SPA fallback for deep links
such as /to-receive).
One workflow, six facets, and a messenger.
- Orders - add a single item manually or build a quick-order list from
the catalogue. Both paths start in
ต้องสั่งซื้อ, ready to send. - Plans - opt-in automatic purchase plans turn the historical usage you enter and the reorder interval into a draft order on schedule, announced to the pharmacy chat for review before anything reaches a supplier.
- Tracks - every order carries its status from Ordered to Delivered through an interface built for one action: confirm the goods, move the line. The status is the state of truth.
- Notifies - the Telegram bot delivers the news when a pharmacist sends an order group, so the team learns from the phone in their pocket, not from a page they forgot to refresh.
- Remembers - the history view holds every past and current order with its current status and dates; an immutable event log in the database keeps actor, status transitions, and server timestamps for the audit.
- Serves - Vue 3 keeps the UI reactive, Pinia keeps the state consistent, Supabase holds the data, and Firebase Hosting ships the build with SPA rewrites already configured.
The core ceremony - an order's journey:
- Add the items manually or with quick order. The purchase list becomes an order, complete.
- Place it. The status reads Ordered - and Telegram announces it when the order is sent to the supplier.
- The goods arrive. One action confirms receipt; the status advances
toward Delivered. Cancelled orders stay in the history with status
ยกเลิก. - Audit anytime: the history view shows every order and its current dates; the immutable event log holds every status transition with actor and server time.
The ceremony of the message - a sent order is never silent. The bot speaks when a pharmacist sends an order group, so "has the order gone out?" is a question the team already knows the answer to.
The ceremony of the record - statuses, actors, and timestamps are written down for every order, every time. If procurement is questioned six months later, the answer is a scroll away in the database - and the current state is always on the history screen.
Where this artifact is heading
order ▸ manual add and quick-order creation ───────────────────── ▸ sealed
track ▸ Ordered to Delivered, one-action updates ──────────────── ▸ sealed
notify ▸ Telegram delivery when an order is sent ───────────────── ▸ sealed
history ▸ current-state history + immutable audit events ────────── ▸ sealed
Raising the artifact - the quality bar is the local checks and the
CI workflow: Deno type-check, ESLint, unit
tests, and the production build. The interface rules live in
DESIGN.md; Supabase schema and edge functions live under
supabase/. Open an issue first to discuss a change.
Quality gates - run bun run lint, bun run type-check,
bun run test, and bun run build; edge functions are checked with
deno check supabase/functions/*/index.ts.
Status - CI runs on every push. The Firebase deploy workflow currently
triggers on main and builds independently, so it does not wait for CI;
gating it on the CI artifact is tracked in #98.
Current scope - order entry is manual add or quick order; CSV bulk import
is not part of this app. Immutable audit events live in
purchase_order_events; the history screen shows current state, and the
event-timeline UI is tracked in #127.
Hosting rewrites serve deep links, but the login flow currently returns to
the order list; preserving the original path is tracked in
#116.
─────────────────────────────────────────
An order unannounced is an order
nobody is waiting for.
─────────────────────────────────────────
Licensed under the MIT License.