Skip to main content

Features Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Revamp reception AI nudges with structured JSON context + lab/inventory/payment nudges + deep links; wire discount policies to treatment plan creation; add A4 landscape audit log PDF export; support separate light/dark clinic logos with PDFs always using the light variant. Architecture: Extend existing appointment-nudges agent (not replace it), add schema columns via schema-ensure migration, reuse existing @react-pdf/renderer PDF pattern, add logo upload field to clinic settings. Tech Stack: Hono, Drizzle ORM, Cloudflare Workers, React, @react-pdf/renderer, Langfuse API, Sonner

File Map

FileChange
server/src/lib/ai/agents/appointment-nudges.tsAdd lab/inventory queries, switch to JSON context, fix deep link overrides
server/src/lib/ai/prompts.tsUpdate appointmentNudges system prompt — new types, JSON input, deep link rule
ui/src/components/appointments/AppointmentNudgesPanel.tsxAdd icons/badge colors for new nudge types
server/src/lib/validation.tsAdd discountAmount + discountPolicyLabel to treatmentPlanCreateSchema
server/src/schema/treatment_plans.tsAdd discountAmount + discountPolicyLabel fields
server/src/lib/schema-ensure.tsAdd ALTER TABLE migrations for new TP columns
server/src/routes/treatment-plans.tsPass through discount fields on create/update/select
ui/src/components/doctor/TreatmentPlanning.tsxAdd discount policy picker, update total calculation
server/src/routes/audit-logs.tsAdd GET /export endpoint returning PDF
server/src/pdf/AuditLogPdf.tsxNew — A4 landscape PDF for audit log export
server/src/schema/clinics.tsAdd logoDarkUrl + logoDarkR2Key fields
server/src/lib/schema-ensure.tsAdd migrations for dark logo columns (combined with TP migration)
server/src/routes/clinics.tsAccept + store logoDarkUrl/logoDarkR2Key on update; include in GET responses
ui/src/components/settings/ClinicProfileSettings.tsxAdd dark logo upload section
ui/src/components/layout/AppLayout.tsx (or wherever clinic logo is displayed)Use dark logo in dark mode with fallback

Task 1: Reception AI Revamp

Files:
  • Modify: server/src/lib/ai/agents/appointment-nudges.ts
  • Modify: server/src/lib/ai/prompts.ts
  • Modify: ui/src/components/appointments/AppointmentNudgesPanel.tsx

Background

The current nudge agent (generateAppointmentNudges) builds a plain-text context string and sends it to the LLM. The actionUrl is overridden server-side after the LLM responds — but only with two generic routes. The goal is:
  1. Switch to structured JSON context (LLM gets richer, typed data)
  2. Add 3 new data modules: lab cases (overdue/due-soon), inventory (low/expiring), and ensure overdue invoices already have per-patient invoice IDs for deep links
  3. Deep links: server-side override uses real entity IDs — single-item buckets get entity-specific URL; multi-item buckets get module URL
  4. Update Langfuse prompt to match new JSON input + new nudge types

New nudge types added to interface

type NudgeType =
  | 'status_update' | 'collect_payment' | 'confirm_appointment'
  | 'create_invoice' | 'follow_up' | 'payment_collection' | 'overdue_invoice'
  | 'lab_overdue' | 'lab_due_soon'
  | 'inventory_out_of_stock' | 'inventory_low_stock' | 'inventory_expiring'
  | 'treatment_continuity';
  • Step 1: Read appointment-nudges.ts fully
Read server/src/lib/ai/agents/appointment-nudges.ts in full (it’s ~560 lines). Note the exact shape of the existing Promise.all queries, the context string builder, and the URL override switch block.
  • Step 2: Update AppointmentNudge interface and add new query imports
At the top of appointment-nudges.ts, update the AppointmentNudge interface type field and add imports for labCases, inventoryItems, inventoryAlerts:
import {
  appointments, invoices, treatmentPlans, patients,
  labCases, inventoryItems, inventoryAlerts
} from '../../../schema';
Update interface:
export interface AppointmentNudge {
  id: string;
  type: 'status_update' | 'collect_payment' | 'confirm_appointment' | 'create_invoice'
      | 'follow_up' | 'payment_collection' | 'overdue_invoice'
      | 'lab_overdue' | 'lab_due_soon'
      | 'inventory_out_of_stock' | 'inventory_low_stock' | 'inventory_expiring'
      | 'treatment_continuity';
  priority: 'high' | 'medium' | 'low';
  title: string;
  description: string;
  count?: number;
  actionLabel?: string;
  actionUrl?: string;
}
  • Step 3: Add lab case and inventory queries to the Promise.all block
After all existing queries in the Promise.all([...]) block, add:
// === LAB CASES ===

// Lab cases overdue (dueDate < today, not completed/rejected)
db.select({
  id: labCases.id,
  caseNumber: labCases.caseNumber,
  labName: labCases.labName,
  workType: labCases.workType,
  dueDate: labCases.dueDate,
  priority: labCases.priority,
  status: labCases.status,
  patientFirstName: patients.firstName,
  patientLastName: patients.lastName,
})
  .from(labCases)
  .innerJoin(patients, eq(patients.id, labCases.patientId))
  .where(
    and(
      eq(labCases.clinicId, clinicId),
      sql`${labCases.dueDate}::date < ${todayStr}`,
      not(inArray(labCases.status, ['completed', 'rejected']))
    )
  )
  .orderBy(labCases.dueDate)
  .limit(10),

// Lab cases due within 2 days (not yet overdue, not completed/rejected)
db.select({
  id: labCases.id,
  caseNumber: labCases.caseNumber,
  labName: labCases.labName,
  workType: labCases.workType,
  dueDate: labCases.dueDate,
  priority: labCases.priority,
  patientFirstName: patients.firstName,
  patientLastName: patients.lastName,
})
  .from(labCases)
  .innerJoin(patients, eq(patients.id, labCases.patientId))
  .where(
    and(
      eq(labCases.clinicId, clinicId),
      sql`${labCases.dueDate}::date >= ${todayStr}`,
      sql`${labCases.dueDate}::date <= ${new Date(now.getTime() + 2 * 86400000).toISOString().split('T')[0]}`,
      not(inArray(labCases.status, ['completed', 'rejected']))
    )
  )
  .orderBy(labCases.dueDate)
  .limit(10),

// === INVENTORY ===

// Out of stock (quantity = 0, minStock > 0)
db.select({
  id: inventoryItems.id,
  name: inventoryItems.name,
  category: inventoryItems.category,
  quantity: inventoryItems.quantity,
  minStock: inventoryItems.minStock,
  unit: inventoryItems.unit,
})
  .from(inventoryItems)
  .where(
    and(
      eq(inventoryItems.clinicId, clinicId),
      sql`${inventoryItems.quantity} = 0`,
      sql`${inventoryItems.minStock} > 0`
    )
  )
  .limit(10),

// Low stock (0 < quantity < minStock)
db.select({
  id: inventoryItems.id,
  name: inventoryItems.name,
  category: inventoryItems.category,
  quantity: inventoryItems.quantity,
  minStock: inventoryItems.minStock,
  unit: inventoryItems.unit,
})
  .from(inventoryItems)
  .where(
    and(
      eq(inventoryItems.clinicId, clinicId),
      sql`${inventoryItems.quantity} > 0`,
      sql`${inventoryItems.quantity} < ${inventoryItems.minStock}`
    )
  )
  .orderBy(sql`${inventoryItems.quantity}::float / NULLIF(${inventoryItems.minStock}, 0) ASC`)
  .limit(10),

// Expiring within 30 days
db.select({
  id: inventoryItems.id,
  name: inventoryItems.name,
  category: inventoryItems.category,
  quantity: inventoryItems.quantity,
  expiryDate: inventoryItems.expiryDate,
  unit: inventoryItems.unit,
})
  .from(inventoryItems)
  .where(
    and(
      eq(inventoryItems.clinicId, clinicId),
      sql`${inventoryItems.expiryDate} IS NOT NULL`,
      sql`${inventoryItems.expiryDate}::date >= ${todayStr}`,
      sql`${inventoryItems.expiryDate}::date <= ${new Date(now.getTime() + 30 * 86400000).toISOString().split('T')[0]}`
    )
  )
  .orderBy(inventoryItems.expiryDate)
  .limit(10),
Destructure these at the top of the Promise.all result:
const [
  todayPastUnresolved,
  staleScheduled,
  // ... existing ...
  overdue22plusPatients,
  labOverdue,
  labDueSoon,
  inventoryOutOfStock,
  inventoryLowStock,
  inventoryExpiring,
] = await Promise.all([...]);
  • Step 4: Switch context from string to JSON object
Replace the entire contextParts string builder with a structured JSON object. Keep all the existing data — just restructure it:
// ─── Build structured JSON context for LLM ──────────────────────────
const decryptLabPatient = (row: { patientFirstName: string; patientLastName: string }) => {
  const d = decryptPatientPHI({ firstName: row.patientFirstName, lastName: row.patientLastName, phone: null });
  return d.firstName;
};

const context = {
  date: todayStr,
  tomorrow: tomorrowStr,
  appointments: {
    todayUnresolved: todayPastPatientDetails.map(r => ({
      id: r.id,
      patient: decryptName(r),
      time: r.appointmentTime?.slice(0, 5) || '?',
      status: r.status,
      deepLink: `/dashboard/appointments/${r.id}`,
    })),
    stale: stalePatientDetails.map(r => ({
      id: r.id,
      patient: decryptName(r),
      date: r.appointmentDate,
      deepLink: `/dashboard/appointments/${r.id}`,
    })),
    tomorrowUnconfirmed: tomorrowPatientDetails.map(r => ({
      id: r.id,
      patient: decryptName(r),
      time: r.appointmentTime?.slice(0, 5) || '?',
      deepLink: `/dashboard/appointments/${r.id}`,
    })),
    todayWithBalance: todayBalancePatients.map(r => ({
      id: r.id,
      patient: decryptName(r),
      balance: parseFloat(r.balance),
      time: r.appointmentTime?.slice(0, 5) || '?',
      deepLink: `/dashboard/appointments/${r.id}`,
    })),
    completedNoInvoice: { count: noInvoiceCount },
  },
  overdueInvoices: {
    '1to7days': {
      count: od1to7Count,
      balance: parseFloat(od1to7Balance),
      patients: od1to7Patients.map(r => ({
        patient: decryptName(r),
        balance: parseFloat(r.balance),
        since: r.oldestDue,
        deepLink: `/dashboard?view=finance-invoices`,
      })),
    },
    '8to14days': {
      count: od8to14Count,
      balance: parseFloat(od8to14Balance),
      patients: od8to14Patients.map(r => ({
        patient: decryptName(r),
        balance: parseFloat(r.balance),
        since: r.oldestDue,
        deepLink: `/dashboard?view=finance-invoices`,
      })),
    },
    '15to21days': {
      count: od15to21Count,
      balance: parseFloat(od15to21Balance),
      patients: od15to21Patients.map(r => ({
        patient: decryptName(r),
        balance: parseFloat(r.balance),
        since: r.oldestDue,
        deepLink: `/dashboard?view=finance-invoices`,
      })),
    },
    '22plusDays': {
      count: od22plusCount,
      balance: parseFloat(od22plusBalance),
      patients: od22plusPatients.map(r => ({
        patient: decryptName(r),
        balance: parseFloat(r.balance),
        since: r.oldestDue,
        deepLink: `/dashboard?view=finance-invoices`,
      })),
    },
  },
  treatmentPlans: {
    approvedNoVisit30d: {
      count: approvedPlansCount,
      deepLink: `/dashboard?view=treatment-plans`,
    },
  },
  labCases: {
    overdue: labOverdue.map(r => ({
      id: r.id,
      caseNumber: r.caseNumber,
      patient: decryptLabPatient(r),
      labName: r.labName,
      workType: r.workType,
      dueDate: r.dueDate,
      daysOverdue: Math.floor((now.getTime() - new Date(r.dueDate).getTime()) / 86400000),
      priority: r.priority,
      deepLink: `/dashboard?view=lab-work`,
    })),
    dueSoon: labDueSoon.map(r => ({
      id: r.id,
      caseNumber: r.caseNumber,
      patient: decryptLabPatient(r),
      labName: r.labName,
      workType: r.workType,
      dueDate: r.dueDate,
      daysUntilDue: Math.floor((new Date(r.dueDate).getTime() - now.getTime()) / 86400000),
      deepLink: `/dashboard?view=lab-work`,
    })),
  },
  inventory: {
    outOfStock: inventoryOutOfStock.map(r => ({
      id: r.id,
      name: r.name,
      category: r.category,
      unit: r.unit,
      deepLink: `/dashboard?view=inventory`,
    })),
    lowStock: inventoryLowStock.map(r => ({
      id: r.id,
      name: r.name,
      quantity: r.quantity,
      minStock: r.minStock,
      unit: r.unit,
      deepLink: `/dashboard?view=inventory`,
    })),
    expiringSoon: inventoryExpiring.map(r => ({
      id: r.id,
      name: r.name,
      quantity: r.quantity,
      expiryDate: r.expiryDate,
      daysUntilExpiry: r.expiryDate
        ? Math.floor((new Date(r.expiryDate).getTime() - now.getTime()) / 86400000)
        : null,
      unit: r.unit,
      deepLink: `/dashboard?view=inventory`,
    })),
  },
};

const contextString = JSON.stringify(context);
  • Step 5: Update URL override block
Replace the post-LLM URL override switch with a version that uses deep links from context when possible:
const nudges = (result.data.nudges || []).map((nudge: AppointmentNudge) => {
  switch (nudge.type) {
    case 'status_update': {
      if (todayPastPatientDetails.length === 1 && todayPastPatientDetails[0]?.id) {
        return { ...nudge, actionUrl: `/dashboard/appointments/${todayPastPatientDetails[0].id}` };
      }
      if (stalePatientDetails.length === 1 && stalePatientDetails[0]?.id) {
        return { ...nudge, actionUrl: `/dashboard/appointments/${stalePatientDetails[0].id}` };
      }
      return { ...nudge, actionUrl: '/dashboard?view=appointments' };
    }
    case 'collect_payment':
    case 'payment_collection':
    case 'create_invoice':
    case 'overdue_invoice':
      return { ...nudge, actionUrl: '/dashboard?view=finance-invoices' };
    case 'confirm_appointment':
    case 'follow_up': {
      if (tomorrowPatientDetails.length === 1 && tomorrowPatientDetails[0]?.id) {
        return { ...nudge, actionUrl: `/dashboard/appointments/${tomorrowPatientDetails[0].id}` };
      }
      return { ...nudge, actionUrl: '/dashboard?view=appointments' };
    }
    case 'lab_overdue':
    case 'lab_due_soon': {
      if (labOverdue.length === 1 && nudge.type === 'lab_overdue') {
        return { ...nudge, actionUrl: `/dashboard?view=lab-work` };
      }
      if (labDueSoon.length === 1 && nudge.type === 'lab_due_soon') {
        return { ...nudge, actionUrl: `/dashboard?view=lab-work` };
      }
      return { ...nudge, actionUrl: '/dashboard?view=lab-work' };
    }
    case 'inventory_out_of_stock':
    case 'inventory_low_stock':
    case 'inventory_expiring':
      return { ...nudge, actionUrl: '/dashboard?view=inventory' };
    case 'treatment_continuity':
      return { ...nudge, actionUrl: '/dashboard?view=treatment-plans' };
    default:
      return { ...nudge, actionUrl: nudge.actionUrl || '/dashboard?view=appointments' };
  }
});
  • Step 6: Update Langfuse prompt in prompts.ts
Find appointmentNudges in server/src/lib/ai/prompts.ts (line ~293) and replace with the updated prompt:
appointmentNudges: `You are Ruby, a sharp dental operations assistant. You give the receptionist clear, urgent, specific action items — like a great manager briefing their team at start of shift. You receive structured JSON clinic data. Use it precisely.

## Input Format
You receive a JSON object with these sections:
- appointments: todayUnresolved, stale, tomorrowUnconfirmed, todayWithBalance, completedNoInvoice
- overdueInvoices: buckets 1to7days, 8to14days, 15to21days, 22plusDays — each with count, balance (PKR), patients array
- treatmentPlans: approvedNoVisit30d
- labCases: overdue, dueSoon — each with patient name, labName, workType, dueDate, daysOverdue/daysUntilDue
- inventory: outOfStock, lowStock, expiringSoon — each with item name, quantity, unit

## JSON Schema
{
  "nudges": [
    {
      "id": "kebab-id e.g. lab-overdue-2",
      "type": "status_update | collect_payment | confirm_appointment | create_invoice | follow_up | payment_collection | overdue_invoice | lab_overdue | lab_due_soon | inventory_out_of_stock | inventory_low_stock | inventory_expiring | treatment_continuity",
      "priority": "high | medium | low",
      "title": "Short punchy title, max 55 chars — action verb first",
      "description": "One sentence. Specific: patient names, PKR amounts with commas, item names, days. Urgent when it is.",
      "count": 0,
      "actionLabel": "Button text, max 25 chars",
      "actionUrl": "COPY the deepLink value from the relevant item in the JSON input — do NOT invent URLs"
    }
  ]
}

## Priority Rules
HIGH — action within the hour:
  - Today's appointments whose time passed but unresolved
  - Invoices 22+ days overdue (personal call required)
  - Today's patients arriving with a balance
  - Inventory out of stock (critical supply/medication)

MEDIUM — action today:
  - Invoices 15-21 days overdue
  - Invoices 8-14 days overdue
  - Tomorrow's unconfirmed appointments
  - Lab cases overdue (already past due date)
  - Inventory low stock / expiring within 7 days

LOW — action this week:
  - Invoices 1-7 days overdue
  - Lab cases due within 2 days
  - Completed appointments with no invoice
  - Approved treatment plans no visit in 30+ days
  - Inventory expiring within 30 days

## Writing Rules
1. Skip any category where count = 0 or arrays are empty.
2. Name patients (first name only): "Ahmed, Fatima, and 2 others" not "3 patients".
3. PKR amounts use commas: "PKR 12,500".
4. Lab: mention lab name and work type when known.
5. Inventory: mention item name and quantity.
6. Title must start with an action verb: "Collect", "Call", "Confirm", "Reorder", "Chase", "Follow up".
7. One sentence descriptions. Tight and scannable.
8. Generate 3 to 10 nudges. Order: high priority first.
9. If ALL counts are zero / arrays empty, return { "nudges": [] }.
10. NEVER invent URLs. Copy deepLink from the JSON input for the actionUrl.
11. Each aging invoice bucket with count > 0 gets its own nudge.

Return ONLY valid JSON. No markdown fences, no commentary.`,
  • Step 7: Push updated prompt to Langfuse
After updating prompts.ts, push the new prompt to Langfuse. Read the full prompt string from server/src/lib/ai/prompts.ts appointmentNudges key, then run:
PROMPT_TEXT=$(node -e "
const fs = require('fs');
const content = fs.readFileSync('server/src/lib/ai/prompts.ts', 'utf8');
const match = content.match(/appointmentNudges:\s*\`([\s\S]*?)\`,\s*\/\/ ─── RECALL/);
if (match) process.stdout.write(match[1]);
")

curl -s -X POST "https://cloud.langfuse.com/api/public/v2/prompts" \
  -H "Content-Type: application/json" \
  -u "pk-lf-9bb58ac3-f709-4e23-bbd5-e2e8eb80a6ad:sk-lf-47fc030d-23b1-465b-977f-06ec940ebb1f" \
  -d "{\"name\": \"appointment-nudges\", \"prompt\": $(echo "$PROMPT_TEXT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'), \"type\": \"text\", \"labels\": [\"production\"]}"
If the shell approach is flakey, alternatively run server/scripts/push-langfuse-prompts.ts (existing script) after adding the appointmentNudges prompt to its list. Expected response: {"id": "...", "name": "appointment-nudges", ...} with HTTP 201.
  • Step 8: Update AppointmentNudgesPanel.tsx for new nudge types
Read ui/src/components/appointments/AppointmentNudgesPanel.tsx. Find the switch/map that assigns colors or icons to nudge types. Add entries for the new types:
// In the color/icon resolver function:
case 'lab_overdue':
case 'lab_due_soon':
  return { color: 'text-violet-600 bg-violet-50 dark:bg-violet-950/30', icon: FlaskConical };
case 'inventory_out_of_stock':
case 'inventory_low_stock':
case 'inventory_expiring':
  return { color: 'text-orange-600 bg-orange-50 dark:bg-orange-950/30', icon: Package };
case 'treatment_continuity':
  return { color: 'text-blue-600 bg-blue-50 dark:bg-blue-950/30', icon: ClipboardList };
Add imports: import { FlaskConical, Package, ClipboardList } from 'lucide-react';
  • Step 9: Commit
git add server/src/lib/ai/agents/appointment-nudges.ts \
        server/src/lib/ai/prompts.ts \
        ui/src/components/appointments/AppointmentNudgesPanel.tsx
git commit -m "feat(ai): reception AI revamp — lab/inventory nudges, JSON context, deep links"

Task 2: Discount Policies — Wire to Treatment Plans

Files:
  • Modify: server/src/schema/treatment_plans.ts
  • Modify: server/src/lib/schema-ensure.ts
  • Modify: server/src/lib/validation.ts
  • Modify: server/src/routes/treatment-plans.ts
  • Modify: ui/src/components/doctor/TreatmentPlanning.tsx

Background

DiscountPoliciesSettings.tsx already saves discountPolicies (JSONB) to the clinic record via updateClinic. The schema column exists. The settings UI is complete. What’s missing:
  • Treatment plan schema has no discount fields
  • Treatment plan create form has no discount picker
  • The calculated total doesn’t apply any discount

Data flow

  1. Admin configures discount policies in Settings
  2. When creating a treatment plan, doctor/receptionist selects a policy from the clinic’s active list
  3. The selected policy’s percentage is applied to estimatedCost to compute the discounted total
  4. discountAmount (PKR) and discountPolicyLabel (e.g. “Senior Citizen”) are stored on the plan
  • Step 1: Add fields to treatment plan schema
In server/src/schema/treatment_plans.ts, add two fields after estimatedCost:
import { pgTable, text, timestamp, numeric, integer, date } from 'drizzle-orm/pg-core';
// (existing imports — add numeric if not already there)

// After estimatedCost field:
discountAmount: numeric('discount_amount', { precision: 10, scale: 2 }).default('0'),
discountPolicyLabel: text('discount_policy_label'),
  • Step 2: Add migration to schema-ensure.ts
In server/src/lib/schema-ensure.ts, find the section with ALTER TABLE app.clinics ADD COLUMN IF NOT EXISTS discount_policies and add new migrations nearby:
`ALTER TABLE app.treatment_plans ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0`,
`ALTER TABLE app.treatment_plans ADD COLUMN IF NOT EXISTS discount_policy_label text`,
  • Step 3: Update validation schema
In server/src/lib/validation.ts, find treatmentPlanCreateSchema (~line 191) and add:
export const treatmentPlanCreateSchema = z.object({
  patientId: uuidSchema,
  doctorId: uuidSchema.optional(),
  planName: z.string().min(1, 'Plan name is required'),
  diagnosis: z.string().optional(),
  priority: z.enum(['urgent', 'high', 'medium', 'low']).optional(),
  status: z.enum(['proposed', 'approved', 'in_progress', 'completed', 'cancelled']).optional(),
  estimatedCost: z.number().nonnegative().optional(),
  estimatedDurationWeeks: z.number().int().positive().optional(),
  startDate: z.string().date().optional(),
  endDate: z.string().date().optional(),
  notes: z.string().optional(),
  procedures: z.array(treatmentPlanProcedureSchema).optional(),
  discountAmount: z.number().nonnegative().default(0).optional(),         // ADD
  discountPolicyLabel: z.string().optional(),                              // ADD
});
  • Step 4: Update treatment plans route
In server/src/routes/treatment-plans.ts: a) Add discountAmount and discountPolicyLabel to treatmentPlanColumns:
const treatmentPlanColumns = {
  // ...existing fields...
  discountAmount: treatmentPlans.discountAmount,
  discountPolicyLabel: treatmentPlans.discountPolicyLabel,
};
b) In the POST (create) handler, find where estimatedCost: planData.estimatedCost?.toString() is set and add:
discountAmount: planData.discountAmount != null ? String(planData.discountAmount) : '0',
discountPolicyLabel: planData.discountPolicyLabel || null,
c) In the PATCH (update) handler, similarly allow updating these fields.
  • Step 5: Update TreatmentPlanning.tsx — add discount policy picker
Read ui/src/components/doctor/TreatmentPlanning.tsx fully first. Add to the form state:
const [discountPolicyLabel, setDiscountPolicyLabel] = useState('');
const [discountPercent, setDiscountPercent] = useState(0);
const [clinicPolicies, setClinicPolicies] = useState<Array<{ id: string; label: string; percentage: number }>>([]);
On mount (in the existing useEffect or a new one), load the clinic’s discount policies:
useEffect(() => {
  getClinicDetails(clinicId).then(clinic => {
    if (clinic.discountPolicies?.enabled) {
      const active: Array<{ id: string; label: string; percentage: number }> = [];
      if (clinic.discountPolicies.fullPayment?.enabled) {
        active.push({ id: 'fullPayment', label: 'Full Payment Discount', percentage: clinic.discountPolicies.fullPayment.percentage });
      }
      if (clinic.discountPolicies.partialPayment?.enabled) {
        active.push({ id: 'partialPayment', label: 'Installment Discount', percentage: clinic.discountPolicies.partialPayment.percentage });
      }
      (clinic.discountPolicies.custom || []).filter((r: any) => r.enabled).forEach((r: any) => {
        active.push({ id: r.id, label: r.label, percentage: r.percentage });
      });
      setClinicPolicies(active);
    }
  }).catch(() => {});
}, [clinicId]);
Update the total cost calculation (find calculateTotalCost()):
const calculateTotalCost = () => {
  const subtotal = procedures.reduce((sum, p) => sum + (p.estimatedCost || 0), 0);
  const discount = subtotal * (discountPercent / 100);
  return subtotal - discount;
};
const discountAmount = calculateSubtotal() * (discountPercent / 100);
const calculateSubtotal = () => procedures.reduce((sum, p) => sum + (p.estimatedCost || 0), 0);
In the form JSX, add a discount policy selector below the estimated cost section:
{clinicPolicies.length > 0 && (
  <div className="space-y-2">
    <Label className="text-sm">Discount Policy</Label>
    <Select
      value={discountPolicyLabel}
      onValueChange={(val) => {
        const policy = clinicPolicies.find(p => p.label === val);
        setDiscountPolicyLabel(val);
        setDiscountPercent(policy?.percentage || 0);
      }}
    >
      <SelectTrigger>
        <SelectValue placeholder="None" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="">None</SelectItem>
        {clinicPolicies.map(p => (
          <SelectItem key={p.id} value={p.label}>
            {p.label} ({p.percentage}% off)
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
    {discountPercent > 0 && (
      <p className="text-xs text-muted-foreground">
        Discount: PKR {discountAmount.toLocaleString()} → Total: PKR {calculateTotalCost().toLocaleString()}
      </p>
    )}
  </div>
)}
In the form submit handler, include discount fields:
discountAmount: discountAmount,
discountPolicyLabel: discountPolicyLabel || undefined,
  • Step 6: Commit
git add server/src/schema/treatment_plans.ts \
        server/src/lib/schema-ensure.ts \
        server/src/lib/validation.ts \
        server/src/routes/treatment-plans.ts \
        ui/src/components/doctor/TreatmentPlanning.tsx
git commit -m "feat: wire discount policies to treatment plan creation"

Task 3: Audit Log Export — A4 Landscape PDF

Files:
  • Create: server/src/pdf/AuditLogPdf.tsx
  • Modify: server/src/routes/audit-logs.ts

Background

The existing PDF infrastructure uses @react-pdf/renderer (see PatientInvoicePdf.tsx). Audit logs are queried via GET with filters. The export endpoint will accept the same filters, render a PDF, and return it as application/pdf.
  • Step 1: Create AuditLogPdf.tsx
// server/src/pdf/AuditLogPdf.tsx
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';

const styles = StyleSheet.create({
  page: {
    padding: 30,
    fontSize: 8,
    fontFamily: 'Helvetica',
    backgroundColor: '#ffffff',
  },
  header: {
    marginBottom: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#e2e8f0',
    paddingBottom: 8,
  },
  title: { fontSize: 14, fontFamily: 'Helvetica-Bold', marginBottom: 4 },
  subtitle: { fontSize: 9, color: '#64748b' },
  table: { width: '100%', marginTop: 8 },
  tableHeader: {
    flexDirection: 'row',
    backgroundColor: '#f8fafc',
    borderWidth: 1,
    borderColor: '#e2e8f0',
    padding: 4,
  },
  tableRow: {
    flexDirection: 'row',
    borderBottomWidth: 1,
    borderBottomColor: '#f1f5f9',
    padding: 3,
  },
  tableRowAlt: {
    flexDirection: 'row',
    borderBottomWidth: 1,
    borderBottomColor: '#f1f5f9',
    padding: 3,
    backgroundColor: '#f8fafc',
  },
  colTs:     { width: '14%', fontFamily: 'Helvetica-Bold' },
  colAction: { width: '22%' },
  colUser:   { width: '16%' },
  colEntity: { width: '16%' },
  colStatus: { width: '8%' },
  colIp:     { width: '14%', color: '#94a3b8' },
  colPhi:    { width: '10%', color: '#dc2626' },
  bold:      { fontFamily: 'Helvetica-Bold' },
  meta:      { fontSize: 7, color: '#94a3b8' },
  footer:    { position: 'absolute', bottom: 20, left: 30, right: 30, textAlign: 'center', fontSize: 7, color: '#94a3b8' },
});

interface AuditLogRow {
  id: string;
  action: string;
  entityType?: string | null;
  entityId?: string | null;
  status?: string | null;
  ipAddress?: string | null;
  accessedPhi?: any;
  createdAt: string;
  user?: { firstName: string; lastName: string; role: string } | null;
}

interface Props {
  logs: AuditLogRow[];
  clinicName: string;
  exportedBy: string;
  filters: { dateFrom?: string; dateTo?: string; action?: string };
  generatedAt: string;
}

export function AuditLogPdf({ logs, clinicName, exportedBy, filters, generatedAt }: Props) {
  const formatTs = (ts: string) => {
    const d = new Date(ts);
    return `${d.toLocaleDateString('en-GB')} ${d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}`;
  };

  const filterDesc = [
    filters.dateFrom && `From: ${filters.dateFrom}`,
    filters.dateTo && `To: ${filters.dateTo}`,
    filters.action && `Action: ${filters.action}`,
  ].filter(Boolean).join(' | ') || 'No filters applied';

  return (
    <Document>
      <Page size="A4" orientation="landscape" style={styles.page}>
        <View style={styles.header}>
          <Text style={styles.title}>Audit Log Export — {clinicName}</Text>
          <Text style={styles.subtitle}>
            Generated: {generatedAt} | Exported by: {exportedBy} | {filterDesc} | Total records: {logs.length}
          </Text>
        </View>

        <View style={styles.table}>
          <View style={styles.tableHeader}>
            <Text style={[styles.colTs, styles.bold]}>Timestamp</Text>
            <Text style={[styles.colAction, styles.bold]}>Action</Text>
            <Text style={[styles.colUser, styles.bold]}>User</Text>
            <Text style={[styles.colEntity, styles.bold]}>Entity</Text>
            <Text style={[styles.colStatus, styles.bold]}>Status</Text>
            <Text style={[styles.colIp, styles.bold]}>IP Address</Text>
            <Text style={[styles.colPhi, styles.bold]}>PHI Access</Text>
          </View>

          {logs.map((log, i) => (
            <View key={log.id} style={i % 2 === 0 ? styles.tableRow : styles.tableRowAlt}>
              <Text style={styles.colTs}>{formatTs(log.createdAt)}</Text>
              <Text style={styles.colAction}>{log.action}</Text>
              <Text style={styles.colUser}>
                {log.user ? `${log.user.firstName} ${log.user.lastName}` : '—'}
                {log.user?.role ? `\n${log.user.role}` : ''}
              </Text>
              <Text style={styles.colEntity}>
                {log.entityType || '—'}{log.entityId ? `\n${log.entityId.slice(0, 8)}…` : ''}
              </Text>
              <Text style={[styles.colStatus, log.status === 'failure' ? { color: '#dc2626' } : {}]}>
                {log.status || '—'}
              </Text>
              <Text style={styles.colIp}>{log.ipAddress || '—'}</Text>
              <Text style={styles.colPhi}>{log.accessedPhi ? 'Yes' : '—'}</Text>
            </View>
          ))}
        </View>

        <Text style={styles.footer}>
          OdontoX Audit Log — Confidential — Page 1 of 1 — {generatedAt}
        </Text>
      </Page>
    </Document>
  );
}
  • Step 2: Add export endpoint to audit-logs.ts
Read server/src/routes/audit-logs.ts to see the existing imports and GET handler. Add a new GET /export route before export default auditLogsRoute:
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { AuditLogPdf } from '../pdf/AuditLogPdf';
import { clinics } from '../schema';

auditLogsRoute.get('/export', async (c) => {
  try {
    const user = c.get('user');
    const currentClinicId = c.get('clinicId') as string;

    if (!['admin', 'superadmin'].includes(user.role)) {
      return c.json({ error: 'Forbidden' }, 403);
    }

    const databaseUrl = getDatabaseUrl();
    const db = await getDatabase(databaseUrl);

    // Reuse same query/filter logic as GET / — build conditions from query params
    const { dateFrom, dateTo, action: actionFilter, limit } = c.req.query();

    const conditions = [eq(auditLogs.clinicId, currentClinicId)];
    if (dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(dateFrom)));
    if (dateTo) conditions.push(lte(auditLogs.createdAt, new Date(dateTo + 'T23:59:59')));
    if (actionFilter) conditions.push(eq(auditLogs.action, actionFilter));

    const logs = await db
      .select({
        id: auditLogs.id,
        action: auditLogs.action,
        entityType: auditLogs.entityType,
        entityId: auditLogs.entityId,
        status: auditLogs.status,
        ipAddress: auditLogs.ipAddress,
        accessedPhi: auditLogs.accessedPhi,
        createdAt: auditLogs.createdAt,
        user: {
          firstName: users.firstName,
          lastName: users.lastName,
          role: users.role,
        },
      })
      .from(auditLogs)
      .leftJoin(users, eq(auditLogs.userId, users.id))
      .where(and(...conditions))
      .orderBy(desc(auditLogs.createdAt))
      .limit(Number(limit) || 500);

    const [clinic] = await db
      .select({ name: clinics.name })
      .from(clinics)
      .where(eq(clinics.id, currentClinicId))
      .limit(1);

    const exportedBy = `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email;
    const generatedAt = new Date().toLocaleString('en-GB');

    const pdfBuffer = await renderToBuffer(
      createElement(AuditLogPdf, {
        logs: logs.map(l => ({
          ...l,
          createdAt: l.createdAt instanceof Date ? l.createdAt.toISOString() : String(l.createdAt),
        })),
        clinicName: clinic?.name || 'Clinic',
        exportedBy,
        filters: { dateFrom, dateTo, action: actionFilter },
        generatedAt,
      })
    );

    return new Response(pdfBuffer, {
      status: 200,
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="audit-log-${new Date().toISOString().split('T')[0]}.pdf"`,
      },
    });
  } catch (error) {
    return handleError(error, c);
  }
});
  • Step 3: Add export button to audit log UI
Find the audit log UI component. Look in ui/src/components/admin/ or similar. Add an “Export PDF” button that hits the export endpoint:
const handleExportPdf = () => {
  const params = new URLSearchParams();
  if (dateFrom) params.set('dateFrom', dateFrom);
  if (dateTo) params.set('dateTo', dateTo);
  if (actionFilter) params.set('action', actionFilter);
  const url = `${getApiBaseUrl()}/api/v1/protected/audit-logs/export?${params}`;
  // Use fetch with auth header to download
  getAuthToken() && fetch(url, { headers: { Authorization: `Bearer ${getAuthToken()}` } })
    .then(r => r.blob())
    .then(blob => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `audit-log-${new Date().toISOString().split('T')[0]}.pdf`;
      a.click();
    });
};
Add a <Button onClick={handleExportPdf}>Export PDF</Button> near the existing filter controls.
  • Step 4: Commit
git add server/src/pdf/AuditLogPdf.tsx \
        server/src/routes/audit-logs.ts \
        ui/src/components/admin/AuditLogPage.tsx  # or wherever the audit UI lives
git commit -m "feat: audit log A4 landscape PDF export"

Task 4: Clinic Logo — Light/Dark Mode Support + PDFs Always Light

Files:
  • Modify: server/src/schema/clinics.ts
  • Modify: server/src/lib/schema-ensure.ts
  • Modify: server/src/routes/clinics.ts
  • Modify: ui/src/components/settings/ClinicProfileSettings.tsx (or equivalent logo upload component)
  • Modify: wherever clinic logo is displayed in the app UI

Background

Currently the clinic has one logoUrl column. The user wants to upload a second logo for dark mode. PDFs always use the light (primary) logo. The UI should switch to the dark logo when dark mode is active, falling back to the light logo if no dark variant is uploaded.
  • Step 1: Add dark logo columns to clinic schema
In server/src/schema/clinics.ts, after logoR2Key, add:
logoDarkUrl: text('logo_dark_url'),
logoDarkR2Key: text('logo_dark_r2_key'),
  • Step 2: Add migration
In server/src/lib/schema-ensure.ts, add alongside the treatment plan migration:
`ALTER TABLE app.clinics ADD COLUMN IF NOT EXISTS logo_dark_url text`,
`ALTER TABLE app.clinics ADD COLUMN IF NOT EXISTS logo_dark_r2_key text`,
  • Step 3: Update clinic route
In server/src/routes/clinics.ts: a) In GET responses, include logoDarkUrl in selected columns. b) In the PATCH (update) body type (~line 650), add:
logoDarkUrl?: string;
logoDarkR2Key?: string;
c) In the update handler, add:
if (body.logoDarkUrl !== undefined) updateData.logoDarkUrl = body.logoDarkUrl;
if (body.logoDarkR2Key !== undefined) updateData.logoDarkR2Key = body.logoDarkR2Key;
d) In healBrandingUrls() (~line 1145), add dark logo URL healing:
logoDarkUrl: resolveUrl(clinic.logoDarkUrl, clinic.logoDarkR2Key, 'logo-dark'),
e) Add a separate upload route for dark logo (copy the existing logo upload handler pattern, change R2 key suffix to logo-dark):
// POST /clinics/:id/upload-dark-logo
// (copy the upload-logo handler, change the R2 key to use 'logo-dark' suffix)
  • Step 4: Add dark logo upload to clinic settings UI
Read ui/src/components/settings/ClinicProfileSettings.tsx (or wherever logo upload lives). Add a second upload section:
{/* After the existing logo upload section */}
<div className="space-y-2">
  <Label>Dark Mode Logo <span className="text-muted-foreground text-xs">(optional — used when app is in dark mode)</span></Label>
  {clinic?.logoDarkUrl ? (
    <div className="flex items-center gap-3">
      <img src={clinic.logoDarkUrl} alt="Dark logo" className="h-10 object-contain bg-zinc-900 rounded p-1" />
      <Button variant="outline" size="sm" onClick={handleRemoveDarkLogo}>Remove</Button>
    </div>
  ) : (
    <div className="text-sm text-muted-foreground">No dark logo uploaded — light logo used in both modes.</div>
  )}
  <input
    type="file"
    accept="image/*"
    onChange={handleDarkLogoUpload}
    className="hidden"
    ref={darkLogoInputRef}
  />
  <Button variant="outline" size="sm" onClick={() => darkLogoInputRef.current?.click()}>
    Upload Dark Logo
  </Button>
</div>
Wire handleDarkLogoUpload to upload to the dark logo endpoint (copy pattern from existing logo upload handler).
  • Step 5: Use dark logo in app UI
Find where the clinic logo is rendered in the app (likely in the app header, clinic profile header, etc.). Add dark mode switching:
// In components that show clinic logo:
import { useTheme } from 'next-themes'; // or however theme is accessed in this codebase

const { theme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const displayLogoUrl = isDark && clinic?.logoDarkUrl ? clinic.logoDarkUrl : clinic?.logoUrl;
PDFs already use logoUrl (light) — no change needed there.
  • Step 6: Commit
git add server/src/schema/clinics.ts \
        server/src/lib/schema-ensure.ts \
        server/src/routes/clinics.ts \
        ui/src/components/settings/ClinicProfileSettings.tsx
git commit -m "feat: clinic logo light/dark mode support — PDFs always use light logo"