// shared.jsx — Shared UI primitives + sample data
// All components exported to window for cross-file use

const { useState, useEffect, useRef } = React;

// ── Icons (inline SVG) ────────────────────────────────────────

function Icon({ name, size = 18, color = 'currentColor', style: s }) {
  const paths = {
    tasks:     'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
    customers: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
    staff:     'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
    classes:   'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z',
    curriculum:'M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5',
    printing:  'M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 14h12v8H6v-8z',
    invoices:  'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 1v5h5M9 13h6M9 17h4',
    forms:     'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM9 12h6M9 16h4',
    emails:    'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6',
    settings:  'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm8.19-2.53a8.94 8.94 0 0 0 .06-.97 8.94 8.94 0 0 0-.06-.97l2.09-1.63c.19-.15.24-.41.12-.62l-1.98-3.43a.5.5 0 0 0-.61-.22l-2.46.99a9.1 9.1 0 0 0-1.67-.97l-.37-2.62a.49.49 0 0 0-.49-.43h-3.97a.49.49 0 0 0-.49.43l-.37 2.62c-.6.25-1.15.57-1.67.97l-2.46-.99a.5.5 0 0 0-.61.22L2.68 9.87c-.13.21-.07.47.12.62l2.09 1.63c-.04.32-.06.64-.06.97s.02.65.06.97l-2.09 1.63c-.19.15-.24.41-.12.62l1.98 3.43c.13.22.39.3.61.22l2.46-.99c.52.4 1.07.72 1.67.97l.37 2.62c.06.25.28.43.49.43h3.97c.21 0 .43-.18.49-.43l.37-2.62c.6-.25 1.15-.57 1.67-.97l2.46.99c.22.08.48 0 .61-.22l1.98-3.43c.13-.21.07-.47-.12-.62l-2.09-1.63z',
    search:    'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm10 2-4.35-4.35',
    calendar:  'M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM3 10h18M8 2v4M16 2v4',
    plus:      'M12 5v14M5 12h14',
    x:         'M18 6 6 18M6 6l12 12',
    chevronL:  'M15 18l-6-6 6-6',
    chevronR:  'M9 18l6-6-6-6',
    trash:     'M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6',
    menu:      'M3 12h18M3 6h18M3 18h18',
    check:     'M20 6 9 17l-5-5',
    sun:       'M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42M12 5a7 7 0 1 0 0 14A7 7 0 0 0 12 5z',
    moon:      'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z',
  };
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none"
      stroke={color} strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round"
      style={{ flexShrink: 0, ...s }}>
      <path d={paths[name] || paths.settings} />
    </svg>
  );
}

// ── Sample Data ───────────────────────────────────────────────

// Default subject colours — the standard palette to apply across the app where suitable.
const SUBJECT_COLORS = {
  YR09: '#C9A227', // yellow
  YR10: '#4A8BB2', // blue
  ADVN: '#4A9B7E', // green
  EXT1: '#9B6AC0', // purple
  EXT2: '#B44040', // red
  STD:  '#3E9E9E', // teal
};

// The signed-in receptionist — their profile drives the email signature footer.
const CURRENT_USER = { name: 'Maya Rodriguez', role: 'Reception', email: 'maya.rodriguez@themathologists.com.au', phone: '(02) 9000 0000', campus: 'Parramatta' };

const SAMPLE_PARENTS = [
  { id: 'p1', firstName: 'Linda', lastName: 'Nguyen', mobile: '0412 345 000', email: 'linda.nguyen@gmail.com' },
  { id: 'p2', firstName: 'David', lastName: 'Chen', mobile: '0423 456 000', email: 'david.chen@gmail.com' },
  { id: 'p3', firstName: 'Anne', lastName: 'Watson', mobile: '0434 567 000', email: 'anne.watson@gmail.com' },
  { id: 'p4', firstName: 'James', lastName: 'Park', mobile: '0445 678 000', email: 'james.park@gmail.com' },
  { id: 'p5', firstName: 'Priya', lastName: 'Patel', mobile: '0456 789 000', email: 'priya.patel@gmail.com' },
  { id: 'p6', firstName: 'Helen', lastName: 'Bradley', mobile: '0467 890 000', email: 'helen.bradley@gmail.com' },
  { id: 'p7', firstName: 'Susan', lastName: 'Kim', mobile: '0478 901 000', email: 'susan.kim@gmail.com' },
  { id: 'p8', firstName: 'Wei', lastName: 'Zhang', mobile: '0489 012 000', email: 'wei.zhang@gmail.com' },
  { id: 'p9', firstName: 'Robert', lastName: 'Johnson', mobile: '0412 111 000', email: 'robert.johnson@gmail.com' },
  { id: 'p10', firstName: 'Carol', lastName: 'Williams', mobile: '0423 333 000', email: 'carol.williams@gmail.com' },
];

const SAMPLE_STUDENTS = [
  { id: 's1', firstName: 'Sarah', lastName: 'Nguyen', mobile: '0412 345 678', email: 'sarah.nguyen@gmail.com', school: 'Parramatta High', classId: 'c1', campus: 'Parramatta', status: 'enrolled', course: 'YR11 ADVN', trialDate: null, parentId: 'p1' },
  { id: 's2', firstName: 'James', lastName: 'Chen', mobile: '0423 456 789', email: 'james.chen@gmail.com', school: 'Bella Vista College', classId: 'c2', campus: 'Bella Vista', status: 'post-trial', course: 'YR12 EXT1', trialDate: '2026-05-18', parentId: 'p2' },
  { id: 's3', firstName: 'Emily', lastName: 'Watson', mobile: '0434 567 890', email: 'emily.watson@gmail.com', school: 'Castle Hill High', classId: 'c1', campus: 'Parramatta', status: 'trial', course: 'YR11 ADVN', trialDate: '2026-05-22', parentId: 'p3' },
  { id: 's4', firstName: 'Michael', lastName: 'Park', mobile: '0445 678 901', email: 'michael.park@gmail.com', school: 'Northmead Creative', classId: null, campus: 'Bella Vista', status: 'lead', course: 'YR12 ADVN', trialDate: null, parentId: 'p4' },
  { id: 's5', firstName: 'Aisha', lastName: 'Patel', mobile: '0456 789 012', email: 'aisha.patel@gmail.com', school: 'Girraween High', classId: 'c1', campus: 'Parramatta', status: 'enrolled', course: 'YR11 ADVN', trialDate: null, parentId: 'p5' },
  { id: 's6', firstName: 'Tom', lastName: 'Bradley', mobile: '0467 890 123', email: 'tom.bradley@gmail.com', school: 'James Ruse', classId: 'c3', campus: 'Parramatta', status: 'post-trial', course: 'YR12 ADVN', trialDate: '2026-05-19', parentId: 'p6' },
  { id: 's7', firstName: 'Lily', lastName: 'Kim', mobile: '0478 901 234', email: 'lily.kim@gmail.com', school: 'Kellyville High', classId: 'c4', campus: 'Bella Vista', status: 'enrolled', course: 'YR10', trialDate: null, parentId: 'p7' },
  { id: 's8', firstName: 'Oliver', lastName: 'Zhang', mobile: '0489 012 345', email: 'oliver.zhang@gmail.com', school: 'Baulkham Hills High', classId: 'c2', campus: 'Bella Vista', status: 'enrolled', course: 'YR12 EXT1', trialDate: null, parentId: 'p8' },
  { id: 's9', firstName: 'Maya', lastName: 'Johnson', mobile: '0412 111 222', email: 'maya.johnson@gmail.com', school: 'Parramatta High', classId: null, campus: 'Parramatta', status: 'dead-trial', course: 'YR11 ADVN', trialDate: '2026-04-28', parentId: 'p9' },
  { id: 's10', firstName: 'Ethan', lastName: 'Williams', mobile: '0423 333 444', email: 'ethan.williams@gmail.com', school: 'Carlingford High', classId: 'c3', campus: 'Parramatta', status: 'enrolled', course: 'YR12 ADVN', trialDate: null, parentId: 'p10' },
];

const SAMPLE_STAFF = [
  { id: 'st1', firstName: 'Jamie', lastName: 'Lee', mobile: '0411 222 333', email: 'jamie.lee@themathologists.com.au', role: 'Senior tutor', subjects: ['ADVN', 'EXT1'] },
  { id: 'st2', firstName: 'Marcus', lastName: 'Taylor', mobile: '0422 333 444', email: 'marcus.taylor@themathologists.com.au', role: 'Junior tutor', subjects: ['YR10', 'ADVN'] },
  { id: 'st3', firstName: 'Priya', lastName: 'Singh', mobile: '0433 444 555', email: 'priya.singh@themathologists.com.au', role: 'Senior tutor', subjects: ['EXT1', 'EXT2'] },
  { id: 'st4', firstName: 'Casey', lastName: 'Chen', mobile: '0444 555 666', email: 'casey.chen@themathologists.com.au', role: 'Marker', subjects: ['ADVN'] },
  { id: 'st5', firstName: 'Alex', lastName: 'Rivera', mobile: '0455 666 777', email: 'alex.rivera@themathologists.com.au', role: 'Reception', subjects: [] },
  { id: 'st6', firstName: 'Sophie', lastName: 'Brown', mobile: '0466 777 888', email: 'sophie.brown@themathologists.com.au', role: 'Junior tutor', subjects: ['YR10', 'STD'] },
];

// A class has no free-text name — it's identified by its ID code (C-001) and
// described by its fields. classLabel() builds a human-readable descriptor
// (course · day time · room) for headings, dropdowns and chips. Two classes
// with the same course/day/time are told apart by their room (and ID).
function classLabel(c) {
  if (!c) return '—';
  const room = c.room ? ` · ${c.room}` : '';
  return `${c.course} · ${c.day.slice(0, 3)} ${c.startTime}–${c.endTime}${room}`;
}

// Term calendar (2026) — drives the live "Term · Week" shown in the top bar.
const TERM_SCHEDULE = [
  { name: 'Term 1, 2026', start: '2026-01-29', weeks: 11 },
  { name: 'Term 2, 2026', start: '2026-04-28', weeks: 10 },
  { name: 'Term 3, 2026', start: '2026-07-21', weeks: 10 },
  { name: 'Term 4, 2026', start: '2026-10-13', weeks: 10 },
];
function currentTermWeek(today = new Date()) {
  const t0 = new Date(today); t0.setHours(0, 0, 0, 0);
  for (const t of TERM_SCHEDULE) {
    const start = new Date(t.start + 'T00:00:00');
    const end = new Date(start); end.setDate(end.getDate() + t.weeks * 7 - 1);
    if (t0 >= start && t0 <= end) {
      return { name: t.name, term: t.name.split(',')[0], week: Math.floor((t0 - start) / (7 * 86400000)) + 1, inTerm: true };
    }
  }
  return { name: null, term: null, week: null, inTerm: false }; // between terms / school holidays
}

// The 10 teaching weeks of Term 2, 2026 — one source of truth shared by the class
// Lessons tab and attendance, so week numbers/dates never drift. `past` = held.
const TERM2_START = '2026-04-28';
function classWeeks() {
  const start = new Date(TERM2_START + 'T00:00:00');
  const today = new Date(); today.setHours(0, 0, 0, 0);
  return Array.from({ length: 10 }, (_, i) => {
    const d = new Date(start); d.setDate(d.getDate() + i * 7);
    const end = new Date(d); end.setDate(end.getDate() + 6);
    return {
      week: i + 1, date: d, end,
      label: d.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }),
      past: d <= today,
      covers: ts => { if (!ts) return false; const x = new Date(ts + 'T00:00:00'); return x >= d && x <= end; },
    };
  });
}

// Attendance lookup — defaults to 'present' if not recorded. SAMPLE_ATTENDANCE is built below.
function attendanceOf(classId, week, studentId) {
  return (((SAMPLE_ATTENDANCE[classId] || {})[week]) || {})[studentId] || 'present';
}

// Absence follow-ups for the most recent held week: enrolled students marked absent
// who haven't been followed up yet. Drives the auto-generated Tasks-page cards.
function absenceFollowUps() {
  const held = classWeeks().filter(w => w.past);
  if (!held.length) return [];
  const w = held[held.length - 1];
  const out = [];
  SAMPLE_CLASSES.filter(c => !c.archived).forEach(c => {
    SAMPLE_STUDENTS.filter(s => s.status === 'enrolled' && s.classId === c.id).forEach(s => {
      const key = `${c.id}:${w.week}:${s.id}`;
      if (attendanceOf(c.id, w.week, s.id) === 'absent' && !ABSENCE_RESOLVED.has(key)) {
        out.push({ key, student: s, cls: c, week: w.week, date: w.label });
      }
    });
  });
  return out;
}

// Term classes cap at 12 students each (capacity). studentCount is derived from
// the enrolled roster and drives the fill badge (available → almost full → full).
const SAMPLE_CLASSES = [
  { id: 'c1', course: 'YR11 ADVN', day: 'Wednesday', startTime: '4:30pm', endTime: '6:30pm', room: 'Room 3',   campus: 'Parramatta', tutorId: 'st1', studentCount: 12, capacity: 12, term: 'Term 2, 2026', type: 'term', delivery: 'in-person' },
  { id: 'c2', course: 'YR12 EXT1', day: 'Monday',    startTime: '5:00pm', endTime: '7:00pm', room: 'Room 1',   campus: 'Bella Vista', tutorId: 'st3', studentCount: 8,  capacity: 12, term: 'Term 2, 2026', type: 'term', delivery: 'online' },
  { id: 'c3', course: 'YR12 ADVN', day: 'Thursday',  startTime: '4:30pm', endTime: '6:30pm', room: 'Room 2',   campus: 'Parramatta', tutorId: 'st1', studentCount: 11, capacity: 12, term: 'Term 2, 2026', type: 'term', delivery: 'in-person' },
  { id: 'c4', course: 'YR10',      day: 'Saturday',  startTime: '10:00am', endTime: '12:00pm', room: 'Main room', campus: 'Bella Vista', tutorId: 'st2', studentCount: 6,  capacity: 12, term: 'Term 2, 2026', type: 'term', delivery: 'online' },
];

// Holiday programs — separate from term classes so they never leak into the
// term class-picker or the by-year dashboard. Customers link to these via their
// `holiday` enrolment list.
// `price` is the flat program fee (GST-inclusive) — set when creating the program on the
// Classes page; invoices add a holiday-program line that pulls this price.
const SAMPLE_HOLIDAY_PROGRAMS = [
  { id: 'hp1', name: 'Term 2 Mock Exam Program', shortName: 'Mock Exam T2', period: 'Jul 2026', campus: 'Parramatta', delivery: 'in-person', type: 'holiday', price: 480 },
  { id: 'hp2', name: 'Summer Head-Start',        shortName: 'Summer Head-Start', period: 'Jan 2026', campus: 'Both', delivery: 'online', type: 'holiday', price: 360 },
  { id: 'hp3', name: 'Term 1 Catch-Up Bootcamp', shortName: 'Catch-Up T1', period: 'Apr 2026', campus: 'Bella Vista', delivery: 'in-person', type: 'holiday', price: 420 },
];

// ── Demo data generator ───────────────────────────────────────
// Bulks the prototype up to ~20 classes and 210 students (assigned
// across those classes) so the Customers dashboard reads richly.
// Uses a seeded RNG so the made-up data is stable between reloads.
(function generateDemoData() {
  let seed = 20260521;
  const rnd  = () => { seed = (seed * 48271) % 2147483647; return seed / 2147483647; };
  const pick = arr => arr[Math.floor(rnd() * arr.length)];
  const int  = (lo, hi) => lo + Math.floor(rnd() * (hi - lo + 1));

  const firstNames = ['Sarah','James','Emily','Michael','Aisha','Tom','Lily','Oliver','Maya','Ethan','Chloe','Lucas','Zoe','Noah','Ava','Liam','Mia','Ryan','Grace','Daniel','Sophie','Jack','Hannah','Leo','Ruby','Max','Ella','Adam','Isla','Nathan','Priya','Arjun','Wei','Hana','Jin','Nina','Omar','Layla','Ben','Tara'];
  const lastNames  = ['Nguyen','Chen','Watson','Park','Patel','Bradley','Kim','Zhang','Johnson','Williams','Smith','Lee','Tran','Singh','Brown','Wang','Taylor','Ali','Cohen','Reddy','Lin','Khan','Davis','Ng','Shah','Wong','Murphy','Ahmed','Roy','Walsh'];
  const schools    = ['Parramatta High','Bella Vista College','Castle Hill High','James Ruse','Girraween High','Baulkham Hills High','Kellyville High','Carlingford High','Northmead High','Cherrybrook Tech'];
  const campuses = ['Parramatta', 'Bella Vista'];
  const courses  = ['YR09', 'YR10', 'YR11 STD', 'YR11 ADVN', 'YR11 EXT1', 'YR12 STD', 'YR12 ADVN', 'YR12 EXT1', 'YR12 EXT2'];
  const days     = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const times    = [['3:30pm','5:30pm'],['4:30pm','6:30pm'],['5:00pm','7:00pm'],['10:00am','12:00pm'],['1:00pm','3:00pm'],['6:00pm','8:00pm']];
  const rooms    = ['Room 1','Room 2','Room 3','Room 4','Main room'];
  const tutors   = SAMPLE_STAFF.filter(s => /tutor/i.test(s.role)).map(s => s.id);
  const mobile   = () => `04${int(10,99)} ${int(100,999)} ${int(100,999)}`;
  const futureDates = ['2026-05-22','2026-05-23','2026-05-25','2026-05-26','2026-05-28','2026-06-01','2026-06-03'];
  const pastDates   = ['2026-04-28','2026-05-05','2026-05-08','2026-05-12','2026-05-15','2026-05-18'];

  // 1) Extend classes up to 20
  for (let i = SAMPLE_CLASSES.length + 1; i <= 20; i++) {
    const campus = pick(campuses), course = pick(courses), day = pick(days);
    const [startTime, endTime] = pick(times);
    SAMPLE_CLASSES.push({
      id: `c${i}`, course, day, startTime, endTime, room: pick(rooms), campus, tutorId: pick(tutors),
      studentCount: 0, capacity: 12, term: 'Term 2, 2026', type: 'term', delivery: rnd() < 0.25 ? 'online' : 'in-person',
    });
  }

  // 2) Status mix, weighted toward enrolled
  const statusPool = [];
  const addN = (st, n) => { for (let k = 0; k < n; k++) statusPool.push(st); };
  addN('enrolled', 62); addN('lead', 10); addN('trial', 7); addN('post-trial', 7);
  addN('dead-lead', 4); addN('dead-trial', 4); addN('disenrolled', 6);

  // 3) Extend students up to 210, grouping them into families (~1.4 kids/parent).
  // Enrolled students never overfill a class (cap = 12); trials sit in a class
  // but don't take an enrolled seat, so they surface in the class roster.
  const enrolledTally = {};
  SAMPLE_STUDENTS.forEach(s => { if (s.status === 'enrolled' && s.classId) enrolledTally[s.classId] = (enrolledTally[s.classId] || 0) + 1; });
  let pCount = SAMPLE_PARENTS.length;
  let parent = null, kids = 0, capKids = 1;
  for (let i = SAMPLE_STUDENTS.length + 1; i <= 210; i++) {
    const status = pick(statusPool);
    let campus, course, classId = null;
    if (status === 'enrolled') {
      const avail = SAMPLE_CLASSES.filter(c => (enrolledTally[c.id] || 0) < (c.capacity || 12));
      if (avail.length) { const cls = pick(avail); campus = cls.campus; course = cls.course; classId = cls.id; enrolledTally[cls.id] = (enrolledTally[cls.id] || 0) + 1; }
      else { campus = pick(campuses); course = pick(courses); }
    } else if (status === 'trial' || status === 'disenrolled') {
      const cls = pick(SAMPLE_CLASSES); campus = cls.campus; course = cls.course; classId = cls.id;
    } else {
      campus = pick(campuses); course = pick(courses);
    }
    if (!parent || kids >= capKids) {
      pCount++;
      const pl = pick(lastNames);
      parent = { id: `p${pCount}`, firstName: pick(firstNames), lastName: pl, mobile: mobile(), email: `${pick(firstNames)}.${pl}@gmail.com`.toLowerCase() };
      SAMPLE_PARENTS.push(parent);
      kids = 0; capKids = rnd() < 0.35 ? 2 : 1;
    }
    kids++;
    const fn = pick(firstNames), ln = parent.lastName;
    let trialDate = null;
    if (status === 'trial') trialDate = pick(futureDates);
    else if (status === 'post-trial' || status === 'dead-trial') trialDate = pick(pastDates);
    SAMPLE_STUDENTS.push({
      id: `s${i}`, firstName: fn, lastName: ln,
      mobile: mobile(), email: `${fn}.${ln}${i}@gmail.com`.toLowerCase(),
      school: pick(schools), classId, campus, status, course, trialDate, parentId: parent.id,
    });
  }

  // 3b) createdAt = date the record entered the system (drives lead aging).
  // Backfills every student, including the hand-written originals.
  const daysAgo = n => { const d = new Date('2026-05-21T00:00:00'); d.setDate(d.getDate() - n); return d.toISOString().slice(0, 10); };
  SAMPLE_STUDENTS.forEach(s => {
    if (!s.createdAt) s.createdAt = daysAgo(s.status === 'lead' ? int(1, 80) : int(5, 220));
  });

  // 3c) Holiday-program enrolments — kept as full history (active + past).
  // ~22% of students also do a holiday program; some also have a past one.
  SAMPLE_STUDENTS.forEach(s => {
    s.holiday = s.holiday || [];
    if (rnd() < 0.22) s.holiday.push({ programId: pick(SAMPLE_HOLIDAY_PROGRAMS).id, status: 'active' });
    if (rnd() < 0.12) s.holiday.push({ programId: pick(SAMPLE_HOLIDAY_PROGRAMS).id, status: 'past' });
  });

  // Holiday-only customers — in the main DB, no term class, one active holiday program.
  for (let k = 0; k < 10; k++) {
    pCount++;
    const pl = pick(lastNames);
    const par = { id: `p${pCount}`, firstName: pick(firstNames), lastName: pl, mobile: mobile(), email: `${pick(firstNames)}.${pl}@gmail.com`.toLowerCase() };
    SAMPLE_PARENTS.push(par);
    const fn = pick(firstNames);
    const idn = 211 + k;
    SAMPLE_STUDENTS.push({
      id: `s${idn}`, firstName: fn, lastName: pl,
      mobile: mobile(), email: `${fn}.${pl}${idn}@gmail.com`.toLowerCase(),
      school: pick(schools), classId: null, campus: pick(campuses), course: pick(courses),
      status: 'enrolled', trialDate: null, parentId: par.id, createdAt: daysAgo(int(2, 60)),
      holiday: [{ programId: pick(SAMPLE_HOLIDAY_PROGRAMS).id, status: 'active' }],
    });
  }

  // 4) Keep every assigned student's course + campus in lockstep with their
  // class, so a student is never shown in a class for a different course/campus.
  SAMPLE_STUDENTS.forEach(s => {
    const c = s.classId && SAMPLE_CLASSES.find(x => x.id === s.classId);
    if (c) { s.course = c.course; s.campus = c.campus; }
  });

  // 5) Recompute each class's enrolled headcount from the assignments
  const counts = {};
  SAMPLE_STUDENTS.forEach(s => { if (s.status === 'enrolled' && s.classId) counts[s.classId] = (counts[s.classId] || 0) + 1; });
  SAMPLE_CLASSES.forEach(c => { c.studentCount = counts[c.id] || 0; });
})();

const SAMPLE_REQUESTS = [
  { id: 'r1', tutorId: 'st2', category: 'Supplies', campus: 'Parramatta', room: 'Room 3', description: "We've run out of whiteboard markers. Need at least 6 black and 3 coloured ones before Wednesday.", urgency: 'high', createdAt: '2026-05-19T09:15:00' },
  { id: 'r2', tutorId: 'st3', category: 'Cleaning', campus: 'Bella Vista', room: 'Main classroom', description: 'The whiteboard surface is badly stained and needs a proper clean before tomorrow\'s class.', urgency: 'medium', createdAt: '2026-05-20T07:30:00' },
  { id: 'r3', tutorId: 'st6', category: 'Material', campus: 'Bella Vista', room: null, description: 'Need supplementary algebra exercises for the YR10 Saturday class — the current booklet has a few errors on page 8.', urgency: 'low', createdAt: '2026-05-20T08:00:00' },
];

const SAMPLE_SELF_TASKS = [
  { id: 't1', type: 'Follow up with customer', title: 'Follow up: Sarah Nguyen', assignee: 'Maya R.', dueDate: '2026-05-20', status: 'open', customerId: 's1' },
  { id: 't2', type: 'Print supplementary material', title: 'Print supplementary: YR12 ADVN', assignee: 'Jamie L.', dueDate: '2026-05-22', status: 'open' },
  { id: 't3', type: 'Generic task', title: 'Call ReckonOne re: Term 2 invoice setup', assignee: 'Maya R.', dueDate: '2026-05-21', status: 'open' },
];

const SAMPLE_LESSONS_PRINT = [
  { id: 'l1', classId: 'c1', className: 'YR11 ADVN — Wed 4:30pm', campus: 'Parramatta', tutor: 'Jamie Lee', lessonCode: 'ADVN-REG-B1-18', topic: 'Calculus', subtopic: 'Rates of Change (E)', date: '2026-05-21', studentCount: 12, printedTH: false, printedQZ: false, printedHW: false },
  { id: 'l2', classId: 'c3', className: 'YR12 ADVN — Thu 4:30pm', campus: 'Parramatta', tutor: 'Jamie Lee', lessonCode: 'ADVN-REG-B2-05', topic: 'Calculus', subtopic: 'Integration Basics', date: '2026-05-22', studentCount: 10, printedTH: true, printedQZ: false, printedHW: false },
  { id: 'l3', classId: 'c4', className: 'YR10 — Sat 10:00am', campus: 'Bella Vista', tutor: 'Marcus Taylor', lessonCode: 'YR10-REG-A1-07', topic: 'Algebra', subtopic: 'Quadratics Introduction', date: '2026-05-24', studentCount: 15, printedTH: false, printedQZ: false, printedHW: false },
];

// Per-class, per-week attendance for the weeks already held — enrolled students
// only, ~8% absent, seeded so it's stable across reloads. Mutable: reception can
// correct it in the class view. ABSENCE_RESOLVED tracks followed-up absences.
const SAMPLE_ATTENDANCE = {};
const ABSENCE_RESOLVED = new Set();
(function generateAttendance() {
  const held = classWeeks().filter(w => w.past).map(w => w.week);
  SAMPLE_CLASSES.forEach(c => {
    let seed = (parseInt(String(c.id).replace(/\D/g, ''), 10) || 1) * 6151 + 7;
    const rnd = () => { seed = (seed * 48271) % 2147483647; return seed / 2147483647; };
    const enrolled = SAMPLE_STUDENTS.filter(s => s.status === 'enrolled' && s.classId === c.id);
    const byWeek = {};
    held.forEach(wk => { const m = {}; enrolled.forEach(s => { m[s.id] = rnd() < 0.08 ? 'absent' : 'present'; }); byWeek[wk] = m; });
    SAMPLE_ATTENDANCE[c.id] = byWeek;
  });
})();

// ── Invoicing: settings, discounts, credits + the calculation engine ──────────
// See INVOICES_SPEC.md. Prototype: data is in-session; email/Stripe are simulated.

// Business + tax + payment settings — editable in Settings → Business. The invoice
// document reads from here, so changing ABN / bank / GST flows through everywhere.
const INVOICE_SETTINGS = {
  businessName: 'The Mathologists Pty Ltd',
  abn: '12 345 678 901',                 // placeholder — edit in Settings → Business
  address: 'Suite 4, 12 Macquarie St, Parramatta NSW 2150',
  email: 'admin@themathologists.com.au',
  phone: '(02) 9000 0000',
  gstRegistered: true,                   // true → "Tax Invoice" + 10% GST + ABN; false → plain "Invoice"
  gstFree: false,                        // false → 10% GST applies to ALL services (incl. private tutoring)
  hourlyRate: 40,                        // base $/hour, GST-inclusive (used when a course has no override)
  courseRates: {},                       // per-course override, e.g. { 'YR12 EXT2': 50 } — empty = base rate for all
  cashDiscountPct: 5,                    // % off for cash payment
  enrolmentFee: 25,                      // one-off enrolment fee (added on demand to an invoice)
  enrolmentFeeLabel: 'Enrolment fee',    // label shown on the invoice line
  paymentTermsDays: 14,                  // invoice due N days after issue
  overdueTaskDays: 7,                    // > N days overdue → auto reception chase task + ⚑ flag
  remindersEnabled: true,                // run the automated reminder sequence (email/SMS)
  reminderHour: '9:00am',                // …each reminder sent at this local time (simulated)
  // Dunning sequence — offsets are days relative to the due date (negative = before). Auto-cancels when paid.
  reminderSequence: [
    { id: 'pre3', offset: -3, channels: ['email', 'sms'], tone: 'Friendly', label: 'Due in 3 days', on: true },
    { id: 'due',  offset: 0,  channels: ['email', 'sms'], tone: 'Friendly', label: 'Due today',      on: true },
    { id: 'od3',  offset: 3,  channels: ['sms'],          tone: 'Gentle',   label: '3 days overdue', on: true },
    { id: 'od7',  offset: 7,  channels: ['email'],        tone: 'Firm',     label: '7 days overdue · reception call', on: true },
    { id: 'od14', offset: 14, channels: ['email', 'sms'], tone: 'Final',    label: 'Final notice · 14 days', on: true },
  ],
  privateRate: 60,                       // default $/h for 1-on-1 private lessons (placeholder)
  dishonourFee: 14.90,                   // passed on to the parent when a direct-debit instalment fails
  cardFeesAbsorbed: true,                // we absorb Stripe card fees — no surcharge passed to customers
  stripeConnected: true,                 // Stripe account linked (real keys/webhooks wired at production)
  stripeAccountName: 'The Mathologists Pty Ltd',
  stripeBaseUrl: 'https://buy.stripe.com/test_PLACEHOLDER',
};

// Canonical course list (used by per-course pricing + filters).
const INVOICE_COURSES = ['YR09', 'YR10', 'YR11 STD', 'YR11 ADVN', 'YR11 EXT1', 'YR12 STD', 'YR12 ADVN', 'YR12 EXT1', 'YR12 EXT2'];
// Per-course hourly rate: an explicit override if set, else the base rate.
function courseRate(course) {
  const o = INVOICE_SETTINGS.courseRates;
  if (course && o && o[course] != null && o[course] !== '') return o[course];
  return INVOICE_SETTINGS.hourlyRate;
}

// Reusable discount types — defined once (Settings → Discounts), then assigned to a
// customer as ongoing (every term) or once-off (next invoice only).
const SAMPLE_DISCOUNT_TYPES = [
  { id: 'd-sib',   name: 'Sibling discount',       kind: 'percent', value: 10 },
  { id: 'd-multi', name: 'Multi-subject discount', kind: 'fixed',   value: 50 },
  { id: 'd-loyal', name: 'Loyalty discount',       kind: 'percent', value: 5  },
  { id: 'd-early', name: 'Early-bird discount',    kind: 'fixed',   value: 20 },
];

// Customer-facing tracking number, e.g. TM-S-0001. Derived from the student id so it
// never drifts. (Distinct from customers.jsx customerCode() which is S-/P- prefixed.)
function customerNo(student) {
  const num = parseInt(String((student && student.id) || '').replace(/\D/g, ''), 10) || 0;
  return `TM-S-${String(num).padStart(4, '0')}`;
}

// Decimal lesson length from a class's times ("4:30pm"–"6:30pm" → 2).
function parseClock(t) {
  const m = /(\d{1,2}):(\d{2})\s*(am|pm)/i.exec(String(t || ''));
  if (!m) return null;
  let h = parseInt(m[1], 10) % 12; if (/pm/i.test(m[3])) h += 12;
  return h + parseInt(m[2], 10) / 60;
}
function lessonHours(cls) {
  if (!cls) return 0;
  const a = parseClock(cls.startTime), b = parseClock(cls.endTime);
  if (a == null || b == null) return 0;
  return Math.max(0, Math.round((b - a) * 100) / 100);
}

// Teaching weeks for any term (generalises classWeeks(), which is fixed to Term 2).
// Returns [{ week, start: Date, end: Date }]. Driven by TERM_SCHEDULE.
// Snap a date back to the Monday of its week so week 1 always starts on a Monday
// (term start dates aren't necessarily Mondays) — keeps weekday labels and dates aligned.
function mondayOf(date) { const d = new Date(date); const dow = (d.getDay() + 6) % 7; d.setDate(d.getDate() - dow); d.setHours(0, 0, 0, 0); return d; }
function termWeeks(termName) {
  const t = TERM_SCHEDULE.find(x => x.name === termName) || TERM_SCHEDULE[0];
  const start = mondayOf(new Date(t.start + 'T00:00:00'));
  return Array.from({ length: t.weeks }, (_, i) => {
    const d = new Date(start); d.setDate(d.getDate() + i * 7);
    const end = new Date(d); end.setDate(end.getDate() + 6);
    return { week: i + 1, start: d, end };
  });
}
const DAY_INDEX = { Monday: 0, Tuesday: 1, Wednesday: 2, Thursday: 3, Friday: 4, Saturday: 5, Sunday: 6 };
function lessonDateInWeek(weekStart, classDay) {
  const d = new Date(weekStart); d.setDate(d.getDate() + (DAY_INDEX[classDay] || 0)); return d;
}

// Default term to bill: the current term if in one, else the next upcoming term.
function defaultBillingTerm(today = new Date()) {
  const tw = currentTermWeek(today);
  if (tw.inTerm) return tw.name;
  const t0 = new Date(today); t0.setHours(0, 0, 0, 0);
  const upcoming = TERM_SCHEDULE.find(t => new Date(t.start + 'T00:00:00') >= t0);
  return (upcoming || TERM_SCHEDULE[TERM_SCHEDULE.length - 1]).name;
}

// Pro-rata, trial-free span: first billed week is the week AFTER the trial (if the
// student trialled inside this term), else an explicit joinedWeek, else week 1.
function computeBilledSpan(student, termName) {
  const weeks = termWeeks(termName);
  const cls = student && SAMPLE_CLASSES.find(c => c.id === student.classId);
  let firstWeek = 1;
  if (student && student.trialDate) {
    const td = new Date(student.trialDate + 'T00:00:00');
    const wk = weeks.find(w => td >= w.start && td <= w.end);
    if (wk) firstWeek = wk.week + 1;
  }
  if (student && student.joinedWeek) firstWeek = student.joinedWeek;
  const lastWeek = weeks.length;
  firstWeek = Math.min(Math.max(1, firstWeek), lastWeek);
  const day = cls ? cls.day : 'Monday';
  return {
    term: termName, firstWeek, lastWeek, billedWeeks: lastWeek - firstWeek + 1,
    startDate: lessonDateInWeek(weeks[firstWeek - 1].start, day),
    endDate: lessonDateInWeek(weeks[lastWeek - 1].start, day),
    weeksCount: weeks.length,
  };
}

// Effective, still-applicable discounts for a student (ongoing always; once-off until used).
function effectiveDiscounts(student) {
  return (student && student.discounts ? student.discounts : []).filter(d => d.scope === 'ongoing' || !d.used);
}

// Apply discounts to a GST-inclusive subtotal: %-first (stacked), then fixed-$.
function applyDiscounts(subtotal, discounts) {
  let running = subtotal; const lines = [];
  (discounts || []).filter(d => d.kind === 'percent').forEach(d => {
    const amt = Math.round(running * (d.value / 100) * 100) / 100;
    lines.push({ name: d.name, kind: 'percent', value: d.value, amount: amt });
    running = Math.max(0, running - amt);
  });
  (discounts || []).filter(d => d.kind === 'fixed').forEach(d => {
    const amt = Math.min(running, d.value);
    lines.push({ name: d.name, kind: 'fixed', value: d.value, amount: amt });
    running = Math.max(0, running - amt);
  });
  return { lines, afterDiscounts: Math.round(running * 100) / 100 };
}

// Full money breakdown (GST-inclusive pricing model). creditBalance is applied after discounts.
function computeInvoiceAmounts({ billedWeeks, lessonHrs, rate, discounts, creditBalance }) {
  const r = rate != null ? rate : INVOICE_SETTINGS.hourlyRate;
  const lineInc = Math.round(billedWeeks * lessonHrs * r * 100) / 100;
  const { lines, afterDiscounts } = applyDiscounts(lineInc, discounts);
  const creditApplied = Math.max(0, Math.min(creditBalance || 0, afterDiscounts));
  const totalInc = Math.round((afterDiscounts - creditApplied) * 100) / 100;
  const gstActive = INVOICE_SETTINGS.gstRegistered && !INVOICE_SETTINGS.gstFree;
  const gst = gstActive ? Math.round((totalInc / 11) * 100) / 100 : 0;
  const exGst = Math.round((totalInc - gst) * 100) / 100;
  const cashPrice = Math.round(totalInc * (1 - INVOICE_SETTINGS.cashDiscountPct / 100) * 100) / 100;
  return { lineInc, discountLines: lines, creditApplied, totalInc, gst, exGst, cashPrice,
    lessonHrs, billedWeeks, rate: r, totalHours: Math.round(billedWeeks * lessonHrs * 100) / 100 };
}

// Auto-incrementing, unique invoice number: INV-YYYY-#### (sequence resets each year).
const INVOICE_SEQ = {};
function nextInvoiceNumber(d = new Date()) {
  const y = d.getFullYear();
  INVOICE_SEQ[y] = (INVOICE_SEQ[y] || 0) + 1;
  return `INV-${y}-${String(INVOICE_SEQ[y]).padStart(4, '0')}`;
}

// Date helpers (named to avoid clashing with tasks.jsx fmtDate()).
function addDays(date, n) { const d = new Date(date); d.setDate(d.getDate() + n); return d; }
function toISODate(d) { const x = new Date(d); return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, '0')}-${String(x.getDate()).padStart(2, '0')}`; }
function fmtAUDate(d) { try { return new Date(d).toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: 'numeric' }); } catch (e) { return '—'; } }
function fmtAUD(n) { return `$${(Math.round((n || 0) * 100) / 100).toLocaleString('en-AU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }

const INVOICE_TODAY = new Date('2026-05-22T00:00:00');

// Expected lesson length per course (hours) — drives the Classes-page "unusual duration"
// check (E11). Confirm/adjust these defaults; only ADVN (2.5) and EXT1 (3) are user-stated.
const COURSE_HOURS = { YR09: 2, YR10: 2, STD: 2, ADVN: 2.5, EXT1: 3, EXT2: 3 };
function expectedCourseHours(course) {
  if (!course) return null;
  const key = ['EXT2', 'EXT1', 'ADVN', 'STD', 'YR10', 'YR09'].find(k => course.includes(k));
  return key ? COURSE_HOURS[key] : null;
}

// ── Payments / status ─────────────────────────────────────────
function amountPaid(inv) { return Math.round((inv.payments || []).reduce((a, p) => a + (p.amount || 0), 0) * 100) / 100; }
function creditNotesTotal(inv) { return Math.round((inv.creditNotes || []).reduce((a, c) => a + (c.amount || 0), 0) * 100) / 100; }
function refundsTotal(inv) { return Math.round((inv.refunds || []).reduce((a, r) => a + (r.amount || 0), 0) * 100) / 100; }
function dishonourFeesTotal(inv) { return Math.round((inv.dishonours || []).reduce((a, d) => a + (d.fee || 0), 0) * 100) / 100; }
function invoiceBalance(inv) { return Math.round(((inv.totalInc || 0) - creditNotesTotal(inv) - amountPaid(inv) + refundsTotal(inv) + dishonourFeesTotal(inv)) * 100) / 100; }
function paymentStatus(inv) {
  if (inv.void) return 'void';
  if (inv.writeOff) return 'written-off';
  if (refundsTotal(inv) > 0) return 'refunded';
  const bal = invoiceBalance(inv), paid = amountPaid(inv), cn = creditNotesTotal(inv);
  if (inv.totalInc > 0 && bal <= 0.005) return (cn > 0 && paid <= 0.005) ? 'credited' : 'paid';
  if (paid > 0 || cn > 0) return 'partial';
  return 'unpaid';
}

// Credit notes / write-offs / refunds (M1) — the compliant way to reverse or reduce an
// issued invoice. Mutators are pure (data only); the UI logs them to the audit trail.
const CREDIT_NOTE_SEQ = {};
let CREDIT_NOTE_UID = 0;
function nextCreditNoteNumber(d = INVOICE_TODAY) { const y = d.getFullYear(); CREDIT_NOTE_SEQ[y] = (CREDIT_NOTE_SEQ[y] || 0) + 1; return `CN-${y}-${String(CREDIT_NOTE_SEQ[y]).padStart(4, '0')}`; }
function issueCreditNote(inv, { amount, reason } = {}) {
  const max = Math.round((inv.totalInc - creditNotesTotal(inv)) * 100) / 100;
  const amt = Math.max(0, Math.min(Math.round((amount || 0) * 100) / 100, max));
  const note = { id: 'cn-' + Date.now() + '-' + (++CREDIT_NOTE_UID), number: nextCreditNoteNumber(), date: toISODate(INVOICE_TODAY), amount: amt, reason: reason || '' };
  inv.creditNotes = inv.creditNotes || []; inv.creditNotes.push(note);
  return note;
}
function writeOffInvoice(inv, { reason } = {}) { inv.writeOff = { date: toISODate(INVOICE_TODAY), reason: reason || 'Bad debt — uncollectable' }; }
function reverseWriteOff(inv) { inv.writeOff = null; }
function refundInvoice(inv, { amount, method, reason } = {}) {
  const amt = Math.max(0, Math.round((amount || 0) * 100) / 100);
  inv.refunds = inv.refunds || []; inv.refunds.push({ id: 'rf-' + Date.now(), date: toISODate(INVOICE_TODAY), amount: amt, method: method || null, reason: reason || '' });
}

// ── Payment plans (Stripe direct-debit instalments) ───────────
// Fixed cadence menu; the schedule is auto-built so the LAST instalment lands on/before
// the invoice's term-end date (guardrail). Each cleared instalment is a recorded payment.
function termEndDate(termName) { const w = termWeeks(termName); return w[w.length - 1].end; }
function addCadence(date, cadence) { const d = new Date(date); if (cadence === 'weekly') d.setDate(d.getDate() + 7); else if (cadence === 'fortnightly') d.setDate(d.getDate() + 14); else d.setMonth(d.getMonth() + 1); return d; }
function planInstalments(inv, cadence, firstDate) {
  const end = termEndDate(inv.term);
  const dates = []; let d = new Date(firstDate); d.setHours(0, 0, 0, 0);
  while (d <= end && dates.length < 26) { dates.push(new Date(d)); d = addCadence(d, cadence); }
  const bal = invoiceBalance(inv), n = dates.length; if (n < 1) return [];
  const per = Math.floor((bal / n) * 100) / 100;
  return dates.map((dt, i) => ({ id: `ins-${i}`, date: toISODate(dt), amount: i === n - 1 ? Math.round((bal - per * (n - 1)) * 100) / 100 : per, status: 'scheduled' }));
}
// Which cadences fit before term end (a plan needs >=2 instalments).
function planCadenceOptions(inv, firstDate) {
  return ['weekly', 'fortnightly', 'monthly'].map(c => { const ins = planInstalments(inv, c, firstDate); return { cadence: c, count: ins.length, per: ins[0] ? ins[0].amount : 0, last: ins.length ? ins[ins.length - 1].date : null, fits: ins.length >= 2 }; });
}
function setPaymentPlan(inv, cadence, firstDate) { inv.plan = { cadence, method: 'direct-debit', firstDate: toISODate(firstDate), instalments: planInstalments(inv, cadence, firstDate), createdAt: toISODate(INVOICE_TODAY) }; return inv.plan; }
function cancelPlan(inv) { inv.plan = null; }
// A direct-debit instalment failed → pass the dishonour fee on to the parent.
function dishonourInstalment(inv, instalmentId) {
  if (inv.plan) { const ins = inv.plan.instalments.find(x => x.id === instalmentId); if (ins) ins.status = 'failed'; }
  inv.dishonours = inv.dishonours || []; inv.dishonours.push({ id: 'dh-' + Date.now(), date: toISODate(INVOICE_TODAY), instalmentId, fee: INVOICE_SETTINGS.dishonourFee });
}
// Record a payment; overpayment becomes account credit for the primary student (E12).
function recordPayment(inv, { amount, method, date, reference } = {}) {
  const amt = Math.round((amount || 0) * 100) / 100;
  inv.payments = inv.payments || [];
  inv.payments.push({ amount: amt, method: method || null, date: date || toISODate(INVOICE_TODAY), reference: reference || inv.number });
  if (method) inv.paymentMethod = method;
  const over = Math.round((amountPaid(inv) - inv.totalInc) * 100) / 100;
  if (over > 0) { const st = invoicePrimaryStudent(inv); if (st) st.creditBalance = Math.round(((st.creditBalance || 0) + over) * 100) / 100; }
  return { status: paymentStatus(inv), overpaidToCredit: over > 0 ? over : 0 };
}

// Overdue state — uses payment status + remaining balance.
function invoiceOverdueInfo(inv, today = INVOICE_TODAY) {
  const st = paymentStatus(inv);
  if (['paid', 'void', 'written-off', 'credited', 'refunded'].indexOf(st) !== -1) return { overdue: false, daysOverdue: 0, flag: false };
  const due = new Date(inv.dueDate + 'T00:00:00');
  const days = Math.floor((today - due) / 86400000);
  if (days <= 0) return { overdue: false, daysOverdue: 0, flag: false, dueInDays: Math.ceil((due - today) / 86400000) };
  return { overdue: true, daysOverdue: days, flag: days > INVOICE_SETTINGS.overdueTaskDays };
}

// SMS reminder (F3): active when reminders are on, the invoice is sent, not paid/void,
// and the due date is still ahead — fired `reminderHoursBefore` before due (simulated).
function reminderState(inv, today = INVOICE_TODAY) {
  if (!INVOICE_SETTINGS.smsRemindersEnabled) return { scheduled: false, reason: 'reminders off' };
  const st = paymentStatus(inv);
  if (st === 'paid') return { scheduled: false, reason: 'cancelled — paid' };
  if (st === 'void') return { scheduled: false, reason: 'voided' };
  if (!inv.sent) return { scheduled: false, reason: 'not sent yet' };
  const due = new Date(inv.dueDate + 'T00:00:00');
  if (due < today) return { scheduled: false, reason: 'past due' };
  const next = reminderSchedule(inv, today).next;
  return next ? { scheduled: true, at: next.date, label: `${fmtAUDate(next.date)} · ${INVOICE_SETTINGS.reminderHour}` } : { scheduled: false, reason: 'sequence complete' };
}

// Dunning sequence (F4): a series of reminders anchored to the due date, auto-cancelled when
// paid/void. Each step has channels (email/sms) + a tone. Returns steps with computed dates + status.
function reminderSchedule(inv, today = INVOICE_TODAY) {
  const enabled = !!INVOICE_SETTINGS.remindersEnabled;
  const st = paymentStatus(inv);
  const active = enabled && inv.sent && ['paid', 'void', 'written-off', 'credited', 'refunded'].indexOf(st) === -1;
  const due = new Date((inv.dueDate || toISODate(today)) + 'T00:00:00');
  const steps = (INVOICE_SETTINGS.reminderSequence || []).filter(s => s.on !== false)
    .map(s => { const d = addDays(due, s.offset); return { ...s, date: toISODate(d), _t: d.getTime() }; })
    .sort((a, b) => a._t - b._t);
  let nextSet = false;
  const out = steps.map(s => {
    let status;
    if (!enabled) status = 'off';
    else if (!inv.sent) status = 'awaiting send';
    else if (!active) status = st === 'paid' ? 'cancelled · paid' : 'inactive';
    else if (s._t < today.getTime()) status = 'sent';
    else if (!nextSet) { status = 'next'; nextSet = true; }
    else status = 'scheduled';
    return { id: s.id, label: s.label, tone: s.tone, channels: s.channels, date: s.date, status };
  });
  return { active, enabled, steps: out, next: active ? out.find(s => s.status === 'next') || null : null };
}

// Attendance check for absence-credit claims (F7): was the student marked present that week?
// Returns true/false, or null when we have no attendance on file. Backed by student.attendance.
function studentAttendedWeek(studentId, term, week) {
  const s = SAMPLE_STUDENTS.find(x => x.id === studentId);
  if (!s || !s.attendance || !s.attendance[term]) return null;
  return s.attendance[term].present.indexOf(week) !== -1;
}
// One absence credit per student per term — flag a claim if the student already has one this term.
function creditClaimAlreadyThisTerm(claim) {
  const s = SAMPLE_STUDENTS.find(x => x.id === claim.studentId);
  if (s && s.creditClaimedTerm === claim.term) return true;
  return SAMPLE_CREDIT_CLAIMS.some(c => c.id !== claim.id && c.studentId === claim.studentId && c.term === claim.term && c.status === 'approved');
}
// Default rejection reasons offered to reception (plus a free-text custom note).
const CLAIM_REJECT_REASONS = ['Already at the one-credit-per-term limit', 'Student was marked present that week', 'Outside the credit policy window'];

// ── Line-based invoice engine (multi-line, multi-student) ─────
// line: { id, studentId, kind:'class'|'private'|'holiday'|'custom', classId?, programId?,
//   course?, term?, firstWeek?, lastWeek?, startDate?, endDate?, hours, rate, qty, description }
function lineAmount(line) {
  if (line.kind === 'private' && Array.isArray(line.sessions)) {
    return Math.round(line.sessions.reduce((a, s) => a + lessonHours({ startTime: s.startTime, endTime: s.endTime }) * (line.rate || 0), 0) * 100) / 100;
  }
  const hours = line.hours != null ? line.hours : 1;
  return Math.round(hours * (line.rate || 0) * (line.qty || 1) * 100) / 100;
}
let _lnSeq = 0;
function newLineId() { return `ln${++_lnSeq}-${Date.now().toString(36)}`; }

// Build a class line for a student from a class + term (+ optional week-span override).
function classLine(student, cls, term, over = {}) {
  const t = term || (cls && cls.term) || defaultBillingTerm();
  const span = computeBilledSpan({ ...student, classId: cls ? cls.id : student.classId }, t);
  const fw = over.firstWeek || span.firstWeek, lw = over.lastWeek || span.lastWeek;
  const weeks = termWeeks(t); const day = cls ? cls.day : 'Monday';
  const course = cls ? cls.course : student.course;
  const excl = (over.excludedWeeks || []).filter(w => w >= fw && w <= lw);   // non-running weeks (e.g. public holidays)
  return {
    id: newLineId(), studentId: student.id, kind: 'class', classId: cls ? cls.id : null,
    course, term: t, firstWeek: fw, lastWeek: lw,
    startDate: toISODate(lessonDateInWeek(weeks[fw - 1].start, day)),
    endDate: toISODate(lessonDateInWeek(weeks[lw - 1].start, day)),
    hours: lessonHours(cls), rate: over.rate != null ? over.rate : courseRate(course), qty: (lw - fw + 1) - excl.length,
    excludedWeeks: excl, excludedNote: over.excludedNote || (excl.length ? 'No lesson (public holiday)' : ''),
    description: course,
  };
}
// One-off enrolment / registration fee line.
function enrolmentFeeLine(student) {
  return { id: newLineId(), studentId: student.id, kind: 'custom', course: null, hours: null,
    rate: INVOICE_SETTINGS.enrolmentFee || 0, qty: 1, description: INVOICE_SETTINGS.enrolmentFeeLabel || 'Enrolment fee' };
}
function holidayLine(student, program) {
  return { id: newLineId(), studentId: student.id, kind: 'holiday', programId: program.id,
    course: null, hours: null, rate: program.price || 0, qty: 1, description: `Holiday program — ${program.shortName || program.name}` };
}
// A regular WEEKLY private lesson across a term — billed like a class (Start/End weeks),
// but 1-on-1 (no group classId) with its own day/time and the private rate. Renders in the
// term-tuition table; ad-hoc 'private' lines (sessions[]) render in the separate table.
function privateTermLine(student, opts = {}) {
  const t = opts.term || defaultBillingTerm();
  const weeks = termWeeks(t);
  const day = opts.day || 'Monday';
  const fw = Math.min(Math.max(1, opts.firstWeek || 1), weeks.length);
  const lw = Math.min(Math.max(fw, opts.lastWeek || weeks.length), weeks.length);
  const startTime = opts.startTime || '4:00pm', endTime = opts.endTime || '5:00pm';
  return {
    id: newLineId(), studentId: student.id, kind: 'private-term', course: null,
    description: opts.description || 'PRIVATE 1-on-1', day, startTime, endTime,
    term: t, firstWeek: fw, lastWeek: lw,
    startDate: toISODate(lessonDateInWeek(weeks[fw - 1].start, day)),
    endDate: toISODate(lessonDateInWeek(weeks[lw - 1].start, day)),
    hours: lessonHours({ startTime, endTime }), qty: lw - fw + 1,
    rate: opts.rate != null ? opts.rate : INVOICE_SETTINGS.privateRate,
  };
}

// Compute totals from lines. Discounts + credits are applied PER STUDENT (so siblings on
// one invoice each get their own sibling discount / credit on their own portion).
function computeInvoiceFromLines({ lines, discountsByStudent = {}, applyCredit = true, students }) {
  const stOf = id => (students && students[id]) || SAMPLE_STUDENTS.find(s => s.id === id);
  const byStudent = {};
  lines.forEach(l => { (byStudent[l.studentId] = byStudent[l.studentId] || []).push(l); });
  const itemized = lines.map(l => ({ ...l, amount: lineAmount(l) }));
  const subtotal = Math.round(itemized.reduce((a, l) => a + l.amount, 0) * 100) / 100;
  const discountLines = []; const creditLines = [];
  let afterDiscounts = 0, creditApplied = 0;
  Object.keys(byStudent).forEach(sid => {
    const st = stOf(sid);
    const sub = Math.round(byStudent[sid].reduce((a, l) => a + lineAmount(l), 0) * 100) / 100;
    const { lines: dl, afterDiscounts: sa } = applyDiscounts(sub, discountsByStudent[sid] || []);
    dl.forEach(d => discountLines.push({ ...d, studentId: sid }));
    let after = sa;
    if (applyCredit && st && st.creditBalance > 0) {
      const c = Math.max(0, Math.min(st.creditBalance, after));
      if (c > 0) { creditLines.push({ studentId: sid, amount: Math.round(c * 100) / 100 }); creditApplied += c; after -= c; }
    }
    afterDiscounts += sa;
  });
  afterDiscounts = Math.round(afterDiscounts * 100) / 100;
  creditApplied = Math.round(creditApplied * 100) / 100;
  const totalInc = Math.round((afterDiscounts - creditApplied) * 100) / 100;
  const gstActive = INVOICE_SETTINGS.gstRegistered && !INVOICE_SETTINGS.gstFree;
  const gst = gstActive ? Math.round((totalInc / 11) * 100) / 100 : 0;
  const exGst = Math.round((totalInc - gst) * 100) / 100;
  const cashPrice = Math.round(totalInc * (1 - INVOICE_SETTINGS.cashDiscountPct / 100) * 100) / 100;
  return { itemized, subtotal, discountLines, creditLines, creditApplied, totalInc, gst, exGst, cashPrice };
}

// Assemble a full invoice record from a spec (used by seed, single, batch, roll-over).
function buildInvoiceRecord(spec, number) {
  const calc = computeInvoiceFromLines({ lines: spec.lines, discountsByStudent: spec.discountsByStudent, applyCredit: spec.applyCredit });
  const issue = spec.issueDate || toISODate(INVOICE_TODAY);
  const classLines = spec.lines.filter(l => l.kind === 'class');
  const primary = SAMPLE_STUDENTS.find(s => s.id === (spec.lines[0] || {}).studentId);
  return {
    number: number || 'DRAFT', draft: !number,
    billToParentId: spec.billToParentId, studentIds: [...new Set(spec.lines.map(l => l.studentId))],
    customerNo: primary ? customerNo(primary) : '',
    lines: calc.itemized, term: (classLines[0] && classLines[0].term) || spec.term || defaultBillingTerm(),
    subtotal: calc.subtotal, discountsApplied: calc.discountLines, creditLines: calc.creditLines, creditApplied: calc.creditApplied,
    totalInc: calc.totalInc, gst: calc.gst, exGst: calc.exGst, cashPrice: calc.cashPrice,
    issueDate: issue, dueDate: toISODate(addDays(new Date(issue + 'T00:00:00'), INVOICE_SETTINGS.paymentTermsDays)),
    sendTo: spec.sendTo || 'parent', paymentMethod: spec.paymentMethod || null, notes: spec.notes || '',
    payments: [], creditNotes: [], refunds: [], dishonours: [], plan: null, writeOff: null, void: false, sent: false, sentAt: null, sendHistory: [], receiptSentAt: null, sendError: null, createdAt: toISODate(INVOICE_TODAY),
  };
}

// ── Invoice summary helpers (for list / cards / drawers) ──────
function invoiceCourses(inv) { return [...new Set((inv.lines || []).filter(l => l.course).map(l => l.course))]; }
function invoiceCoursesLabel(inv) {
  const c = invoiceCourses(inv);
  if (c.length) return c[0] + (c.length > 1 ? ` +${c.length - 1}` : '');
  const l = (inv.lines || [])[0];
  return l ? l.description.replace(/^Holiday program — |^Private tutoring — /, '') : '—';
}
function invoiceWeeksLabel(inv) { const cl = (inv.lines || []).find(l => l.kind === 'class'); return cl ? `Wk ${cl.firstWeek}–${cl.lastWeek}` : `${(inv.lines || []).length} item${(inv.lines || []).length === 1 ? '' : 's'}`; }
function invoicePrimaryStudent(inv) { const id = (inv.studentIds && inv.studentIds[0]) || inv.studentId; return SAMPLE_STUDENTS.find(s => s.id === id); }
function invoiceBillTo(inv) { return SAMPLE_PARENTS.find(p => p.id === inv.billToParentId); }
function invoiceCoversStudent(inv, sid) { return (inv.studentIds || []).indexOf(sid) !== -1; }
function invoiceRecipients(inv) {
  const p = invoiceBillTo(inv);
  const out = [];
  if (inv.sendTo !== 'student' && p) out.push(p.email);
  if (inv.sendTo !== 'parent') (inv.studentIds || []).forEach(id => { const s = SAMPLE_STUDENTS.find(x => x.id === id); if (s && s.email) out.push(s.email); });
  return out;
}

// Seed billing fields on every student (credit balance + assigned discounts), with a
// few illustrative assignments so the demo reads richly.
(function seedBilling() {
  SAMPLE_STUDENTS.forEach(s => {
    if (s.creditBalance == null) s.creditBalance = 0;
    if (!s.discounts) s.discounts = [];
    if (s.joinedWeek === undefined) s.joinedWeek = null;
    if (s.creditClaimedTerm === undefined) s.creditClaimedTerm = null;
  });
  const mk = (s, typeId, scope) => { const t = SAMPLE_DISCOUNT_TYPES.find(d => d.id === typeId); s.discounts.push({ id: `${s.id}-${typeId}`, typeId: t.id, name: t.name, kind: t.kind, value: t.value, scope, used: false }); };
  // Siblings (shared parentId) → ongoing sibling discount on the younger ones.
  const byParent = {};
  SAMPLE_STUDENTS.forEach(s => { (byParent[s.parentId] = byParent[s.parentId] || []).push(s); });
  Object.values(byParent).forEach(kids => { if (kids.length > 1) kids.slice(1).forEach(s => mk(s, 'd-sib', 'ongoing')); });
  // A handful of multi-subject / once-off discounts and absence credits.
  SAMPLE_STUDENTS.filter(s => s.status === 'enrolled' && s.classId).forEach((s, i) => {
    if (i % 11 === 3) mk(s, 'd-multi', 'ongoing');
    if (i % 13 === 5) mk(s, 'd-early', 'once');
    if (i % 9 === 2)  { const c = SAMPLE_CLASSES.find(x => x.id === s.classId); s.creditBalance = Math.round(lessonHours(c) * INVOICE_SETTINGS.hourlyRate); s.creditClaimedTerm = 'Term 2, 2026'; }
  });
  // Attendance for verifying absence-credit claims (F7). s5 genuinely absent Wk 3; s1 present Wk 4.
  const s5 = SAMPLE_STUDENTS.find(s => s.id === 's5'); if (s5) s5.attendance = { 'Term 2, 2026': { present: [1, 2, 4, 5, 6, 7] } };
  // s1 was present Wk 4 AND already used a credit this term → cc2 should be flagged on both counts.
  const s1 = SAMPLE_STUDENTS.find(s => s.id === 's1'); if (s1) { s1.attendance = { 'Term 2, 2026': { present: [1, 2, 3, 4, 5, 6, 7] } }; s1.creditClaimedTerm = 'Term 2, 2026'; }
})();

// Pending absence-credit claims awaiting reception approval (see Customer drawer / Tasks).
// cc1 — student was absent (legit). cc2 — system shows the student present that week (flag to verify).
const SAMPLE_CREDIT_CLAIMS = [
  { id: 'cc1', studentId: 's5', term: 'Term 2, 2026', week: 3, reason: 'Sick — doctor’s appointment', amount: 80, status: 'pending', createdAt: '2026-05-18' },
  { id: 'cc2', studentId: 's1', term: 'Term 2, 2026', week: 4, reason: 'Away — family event', amount: 100, status: 'pending', createdAt: '2026-05-20' },
];

// Seed full line-based invoice records so the list, statuses, partial/overdue states and a
// combined-sibling family invoice are populated on load.
const SAMPLE_INVOICES = (function seedInvoices() {
  const out = [];
  const discFor = sids => { const d = {}; sids.forEach(id => { const s = SAMPLE_STUDENTS.find(x => x.id === id); d[id] = effectiveDiscounts(s).map(x => ({ ...x })); }); return d; };
  const lineFor = sid => { const s = SAMPLE_STUDENTS.find(x => x.id === sid); const cls = SAMPLE_CLASSES.find(c => c.id === s.classId); return classLine(s, cls, (cls && cls.term) || 'Term 2, 2026'); };
  const mk = (spec, opts) => {
    const number = nextInvoiceNumber(new Date(opts.issue + 'T00:00:00'));
    const rec = buildInvoiceRecord({ ...spec, issueDate: opts.issue }, number);
    rec.id = number.toLowerCase();
    rec.sent = opts.sent !== false; rec.sentAt = rec.sent ? opts.issue : null;
    rec.sendHistory = rec.sent ? [{ at: opts.issue, to: spec.sendTo || 'parent' }] : [];
    rec.paymentMethod = opts.method || null; rec.notes = opts.notes || '';
    if (opts.payFull) { rec.payments = [{ amount: rec.totalInc, method: opts.method || 'direct-debit', date: toISODate(addDays(new Date(opts.issue + 'T00:00:00'), 5)), reference: number }]; rec.receiptSentAt = toISODate(addDays(new Date(opts.issue + 'T00:00:00'), 5)); }
    else if (opts.payPart) rec.payments = [{ amount: opts.payPart, method: opts.method || 'cash', date: toISODate(addDays(new Date(opts.issue + 'T00:00:00'), 6)), reference: number }];
    // keep student records consistent: consume applied credit + mark once-off discounts used
    rec.creditLines.forEach(c => { const s = SAMPLE_STUDENTS.find(x => x.id === c.studentId); if (s) s.creditBalance = Math.max(0, Math.round(((s.creditBalance || 0) - c.amount) * 100) / 100); });
    rec.discountsApplied.forEach(d => { const s = SAMPLE_STUDENTS.find(x => x.id === d.studentId); if (s && s.discounts) s.discounts.forEach(x => { if (x.scope === 'once' && !x.used && x.name === d.name) x.used = true; }); });
    out.push(rec);
    return rec;
  };
  const single = (sid, opts, extra) => { const s = SAMPLE_STUDENTS.find(x => x.id === sid); mk({ billToParentId: s.parentId, lines: [lineFor(sid)].concat(extra || []), discountsByStudent: discFor([sid]), applyCredit: true, sendTo: opts.sendTo }, opts); };

  // s1 — multi-line: term class + extra private lessons
  single('s1', { issue: '2026-04-20', method: 'direct-debit', payFull: true, sendTo: 'parent' }, [
    privateTermLine(SAMPLE_STUDENTS.find(s => s.id === 's1'), { day: 'Monday', startTime: '5:00pm', endTime: '6:00pm', term: 'Term 2, 2026', firstWeek: 1, lastWeek: 10, rate: INVOICE_SETTINGS.privateRate }),
    { id: newLineId(), studentId: 's1', kind: 'private', course: null, rate: INVOICE_SETTINGS.privateRate, description: 'PRIVATE 1-on-1', sessions: [
      { date: '2026-05-02', startTime: '4:00pm', endTime: '5:00pm' },
      { date: '2026-05-06', startTime: '5:00pm', endTime: '6:30pm' },
      { date: '2026-05-14', startTime: '4:00pm', endTime: '5:00pm' },
      { date: '2026-05-23', startTime: '10:00am', endTime: '12:00pm' },
    ] },
  ]);
  single('s5',  { issue: '2026-05-12', method: null,     sendTo: 'parent' });
  single('s7',  { issue: '2026-04-25', method: 'cash',   payPart: 200, sendTo: 'both', notes: 'Parent called 18/05 — paying balance next week' }); // partial + >7d overdue
  single('s8',  { issue: '2026-04-21', method: 'stripe', payFull: true, sendTo: 'parent' });
  single('s10', { issue: '2026-05-01', method: 'direct-debit',   sendTo: 'parent', notes: 'Mid-term joiner — pro-rata' }); // ≤7d overdue

  // Combined-siblings family invoice (one bill to the parent for >=2 enrolled, classed kids)
  const fam = (function () { const by = {}; SAMPLE_STUDENTS.forEach(s => { if (s.status === 'enrolled' && s.classId) (by[s.parentId] = by[s.parentId] || []).push(s); }); return Object.values(by).find(k => k.length >= 2); })();
  if (fam) { const sids = fam.map(s => s.id); mk({ billToParentId: fam[0].parentId, lines: sids.map(lineFor), discountsByStudent: discFor(sids), applyCredit: true, sendTo: 'parent' }, { issue: '2026-05-06', method: null, sendTo: 'parent', notes: 'Family invoice — siblings combined' }); }

  // A genuinely uncollectable invoice, written off as bad debt → shows under the "Written off" tab.
  single('s6', { issue: '2026-03-18', method: null, sendTo: 'parent', notes: 'Parent unreachable since Week 2 — debt uncollectable' });
  const badDebt = out.find(r => (r.studentIds || []).indexOf('s6') !== -1);
  if (badDebt) writeOffInvoice(badDebt, { reason: 'Bad debt — parent unreachable, uncollectable' });

  // A draft created but not yet emailed → demonstrates the "Created but not emailed" flag + Drafts tab.
  single('s2', { issue: '2026-05-20', sent: false, sendTo: 'parent', notes: 'Draft — created, not yet emailed' });

  // A couple of issued adjustment (credit) notes so the register reads richly.
  const adj1 = out.find(r => (r.studentIds || []).indexOf('s10') !== -1);
  if (adj1) issueCreditNote(adj1, { amount: 80, reason: 'Pro-rata correction — joined Wk 4' });
  const adj2 = out.find(r => (r.studentIds || []).length > 1);
  if (adj2 && adj2 !== adj1) issueCreditNote(adj2, { amount: 40, reason: 'Credited 1 missed lesson' });
  return out;
})();

// ── Status colours ────────────────────────────────────────────

const STATUS_MAP = {
  'lead': '#5B8FCF', 'dead-lead': '#7A7367', 'trial': '#D4A03A',
  'post-trial': '#D4A03A', 'dead-trial': '#7A7367', 'enrolled': '#4A9B7E',
  'disenrolled': '#C05656', 'paid': '#4A9B7E', 'unpaid': '#D4A03A',
  'overdue': '#C05656', 'high': '#C05656', 'medium': '#D4A03A',
  'low': '#5B8FCF', 'parent': '#7A7367',
};
function getStatusColor(s) { return STATUS_MAP[s] || '#7A7367'; }

// ── Badge ─────────────────────────────────────────────────────

function Badge({ status, label }) {
  const c = getStatusColor(status || label?.toLowerCase?.() || '');
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center',
      padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500,
      letterSpacing: '0.03em', whiteSpace: 'nowrap', textTransform: 'capitalize',
      background: `color-mix(in srgb, ${c} 15%, transparent)`,
      color: c, border: `1px solid color-mix(in srgb, ${c} 30%, transparent)`,
    }}>{label || status}</span>
  );
}

// ── Avatar ────────────────────────────────────────────────────

const AV_COLORS = ['#2A6451','#4A7C9B','#8B5A2B','#6B4F8B','#7A5C3D','#3D6B5A','#7A4E6B'];
function Avatar({ name = '', size = 32 }) {
  const initials = name.split(' ').map(w => w[0]).join('').slice(0,2).toUpperCase() || '?';
  const bg = AV_COLORS[(name.charCodeAt(0) || 0) % AV_COLORS.length];
  return (
    <div style={{ width: size, height: size, borderRadius: '50%', background: bg, flexShrink: 0,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: size < 28 ? 10 : size < 36 ? 12 : 14, fontWeight: 600, color: '#FBFAF6', letterSpacing: '0.03em' }}>
      {initials}
    </div>
  );
}

// ── Button ────────────────────────────────────────────────────

function Button({ children, variant = 'primary', size = 'md', onClick, disabled, style: xs, icon }) {
  const [hov, setHov] = useState(false);
  const base = { display: 'inline-flex', alignItems: 'center', gap: '6px', border: 'none',
    cursor: disabled ? 'not-allowed' : 'pointer', fontFamily: 'inherit', fontWeight: 500,
    transition: '120ms ease', borderRadius: '4px', whiteSpace: 'nowrap', opacity: disabled ? 0.5 : 1,
    fontSize: size === 'sm' ? '12px' : '13px',
    padding: size === 'sm' ? '5px 10px' : size === 'lg' ? '10px 20px' : '7px 14px' };
  const v = {
    primary:   { background: hov ? '#2A6451' : '#1F4D3D', color: '#FBFAF6' },
    secondary: { background: 'transparent', color: 'var(--text-primary)', border: '1px solid var(--border-default)', ...(hov ? { background: 'var(--bg-hover)' } : {}) },
    ghost:     { background: hov ? 'var(--bg-hover)' : 'transparent', color: hov ? 'var(--text-primary)' : 'var(--text-secondary)' },
    danger:    { background: hov ? '#A04444' : '#C05656', color: '#FBFAF6' },
  };
  return (
    <button style={{ ...base, ...v[variant], ...xs }} onClick={onClick} disabled={disabled}
      onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}>
      {icon}{children}
    </button>
  );
}

// ── Modal ─────────────────────────────────────────────────────

function Modal({ open, onClose, title, children, width = 520 }) {
  if (!open) return null;
  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center',
      justifyContent: 'center', background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(8px)' }}
      onClick={e => e.target === e.currentTarget && onClose()}>
      <div style={{ background: 'var(--bg-elevated)', borderRadius: '12px', width,
        maxWidth: 'calc(100vw - 48px)', boxShadow: '0 12px 32px rgba(0,0,0,0.45), 0 2px 6px rgba(0,0,0,0.3)',
        maxHeight: 'calc(100vh - 80px)', overflow: 'auto', border: '1px solid var(--border-default)' }}>
        {title && (
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            padding: '18px 24px 14px', borderBottom: '1px solid var(--border-soft)' }}>
            <span style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>{title}</span>
            <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer',
              color: 'var(--text-muted)', display: 'flex', alignItems: 'center', padding: '4px', borderRadius: '4px' }}>
              <Icon name="x" size={16} />
            </button>
          </div>
        )}
        <div style={{ padding: '20px 24px' }}>{children}</div>
      </div>
    </div>
  );
}

// ── Drawer ────────────────────────────────────────────────────

function Drawer({ open, onClose, title, children, width = 520, subtitle }) {
  return (
    <>
      {open && <div style={{ position: 'fixed', inset: 0, zIndex: 900, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }} onClick={onClose} />}
      <div style={{ position: 'fixed', top: 0, right: 0, bottom: 0, width, zIndex: 901,
        background: 'var(--bg-surface)', borderLeft: '1px solid var(--border-default)',
        boxShadow: open ? '0 4px 12px rgba(0,0,0,0.35)' : 'none',
        transform: open ? 'translateX(0)' : 'translateX(100%)',
        transition: 'transform 180ms cubic-bezier(.2,.7,.3,1)', display: 'flex', flexDirection: 'column' }}>
        {title && (
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            padding: '18px 24px', borderBottom: '1px solid var(--border-soft)', flexShrink: 0 }}>
            <div>
              <div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>{title}</div>
              {subtitle && <div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>{subtitle}</div>}
            </div>
            <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer',
              color: 'var(--text-muted)', display: 'flex', padding: '4px', borderRadius: '4px' }}>
              <Icon name="x" size={16} />
            </button>
          </div>
        )}
        <div style={{ flex: 1, overflowY: 'auto', padding: '0 24px 24px' }}>{children}</div>
      </div>
    </>
  );
}

// ── Form primitives ───────────────────────────────────────────

function FieldLabel({ children }) {
  return <label style={{ fontSize: '12px', fontWeight: 500, color: 'var(--text-secondary)', display: 'block', marginBottom: '5px' }}>{children}</label>;
}

const inputBaseStyle = {
  background: 'var(--bg-surface-muted)', border: '1px solid var(--border-default)',
  borderRadius: '4px', padding: '8px 12px', fontSize: '13px',
  color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
  width: '100%', boxSizing: 'border-box', transition: '120ms ease',
};

function onFocusStyle(e) { e.target.style.borderColor = '#1F4D3D'; e.target.style.boxShadow = '0 0 0 3px rgba(31,77,61,0.2)'; }
function onBlurStyle(e)  { e.target.style.borderColor = 'var(--border-default)'; e.target.style.boxShadow = 'none'; }

function Input({ label, value, onChange, placeholder, type = 'text', style: xs }) {
  return (
    <div>
      {label && <FieldLabel>{label}</FieldLabel>}
      <input type={type} value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder}
        style={{ ...inputBaseStyle, ...xs }} onFocus={onFocusStyle} onBlur={onBlurStyle} />
    </div>
  );
}

// Canonical dropdown for the whole app — a custom popup (never a native
// <select>), so every dropdown looks identical. FormSelect (customers.jsx) is a
// thin wrapper around this. Options: ['a','b'] or [{ value, label }].
// The menu is portalled to <body> with fixed positioning so it is never clipped
// by a modal/drawer's overflow, and it flips upward near the viewport bottom.
// Any new dropdown should use TMSelect (with a label) or FormSelect (without).
function TMSelect({ label, value, onChange, options = [], placeholder = 'Select…', style: xs, buttonStyle, error, searchable }) {
  const [open, setOpen] = useState(false);
  const [pos,  setPos]  = useState(null); // { left, width, top? , bottom? } in viewport coords
  const [q,    setQ]    = useState('');   // typeahead filter (searchable only)
  const btnRef  = useRef(null);
  const menuRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    const onDown = e => {
      if (btnRef.current && btnRef.current.contains(e.target)) return;
      if (menuRef.current && menuRef.current.contains(e.target)) return;
      setOpen(false);
    };
    // Close on page/ancestor scroll & resize (the fixed menu can't follow the button) —
    // but NOT when scrolling the menu's OWN list (a long, scrollable dropdown).
    const onLeave = e => { if (e && e.target && e.target.nodeType && menuRef.current && menuRef.current.contains(e.target)) return; setOpen(false); };
    document.addEventListener('mousedown', onDown);
    window.addEventListener('scroll', onLeave, true);
    window.addEventListener('resize', onLeave);
    return () => { document.removeEventListener('mousedown', onDown); window.removeEventListener('scroll', onLeave, true); window.removeEventListener('resize', onLeave); };
  }, [open]);

  const norm    = options.map(o => (typeof o === 'string' ? { value: o, label: o } : o));
  const current = norm.find(o => o.value === value);
  const selText = current ? current.label : null;

  const toggle = () => {
    if (open) { setOpen(false); return; }
    setQ('');
    const r = btnRef.current.getBoundingClientRect();
    const below = window.innerHeight - r.bottom;
    const up = below < 260 && r.top > below;
    setPos(up
      ? { left: r.left, width: r.width, bottom: window.innerHeight - r.top + 4 }
      : { left: r.left, width: r.width, top: r.bottom + 4 });
    setOpen(true);
  };

  const shown = searchable && q.trim()
    ? norm.filter(o => String(o.label).toLowerCase().includes(q.trim().toLowerCase()))
    : norm;

  const menu = open && pos && ReactDOM.createPortal(
    <div ref={menuRef} style={{
      position: 'fixed', left: pos.left, width: pos.width, zIndex: 3000,
      ...(pos.top != null ? { top: pos.top } : { bottom: pos.bottom }),
      background: 'var(--bg-elevated)', border: '1px solid var(--border-default)',
      borderRadius: '6px', boxShadow: '0 8px 24px rgba(0,0,0,0.55)', padding: '4px',
      maxHeight: '300px', overflow: 'hidden', display: 'flex', flexDirection: 'column',
    }}>
      {searchable && (
        <input autoFocus value={q} onChange={e => setQ(e.target.value)} onClick={e => e.stopPropagation()} placeholder="Type to filter…"
          style={{ width: '100%', boxSizing: 'border-box', margin: '0 0 4px', padding: '6px 9px', fontSize: '13px',
            background: 'var(--bg-surface-muted)', border: '1px solid var(--border-default)', borderRadius: '4px',
            color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
      )}
      <div style={{ overflowY: 'auto', minHeight: 0 }}>
        {shown.map(o => {
          const isSel = o.value === value;
          return (
            <div key={String(o.value)} onClick={() => { setOpen(false); if (o.value !== value) onChange(o.value); }}
              style={{
                padding: '7px 10px', fontSize: '13px', borderRadius: '4px', cursor: 'pointer',
                color: isSel ? '#4A9B7E' : 'var(--text-primary)',
                background: isSel ? 'rgba(74,155,126,0.1)' : 'transparent',
                display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px',
              }}
              onMouseEnter={e => { if (!isSel) e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
              onMouseLeave={e => { if (!isSel) e.currentTarget.style.background = 'transparent'; }}>
              <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{o.label}</span>
              {isSel && (
                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
                  <path d="M20 6 9 17l-5-5" />
                </svg>
              )}
            </div>
          );
        })}
        {shown.length === 0 && <div style={{ padding: '8px 10px', fontSize: '12px', color: 'var(--text-muted)' }}>{searchable ? 'No matches' : 'No options'}</div>}
      </div>
    </div>,
    document.body
  );

  return (
    <div>
      {label && <FieldLabel>{label}</FieldLabel>}
      <button ref={btnRef} type="button" onClick={toggle}
        style={{
          width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px',
          background: 'var(--bg-surface-muted)',
          border: `1px solid ${error ? '#C05656' : (open ? '#1F4D3D' : 'var(--border-default)')}`,
          borderRadius: '4px', padding: '8px 10px', fontSize: '13px',
          color: selText ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit', cursor: 'pointer',
          boxShadow: open ? '0 0 0 3px rgba(31,77,61,0.2)' : 'none', transition: '120ms ease',
          ...xs, ...buttonStyle,
        }}>
        <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selText || placeholder}</span>
        <span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 18, height: 18,
          borderRadius: '4px', flexShrink: 0, background: 'var(--bg-elevated)', border: '1px solid var(--border-default)' }}>
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" strokeWidth="2.75"
            strokeLinecap="round" strokeLinejoin="round" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: '150ms ease' }}>
            <path d="M6 9l6 6 6-6" />
          </svg>
        </span>
      </button>
      {menu}
    </div>
  );
}

function Textarea({ label, value, onChange, placeholder, rows = 3 }) {
  return (
    <div>
      {label && <FieldLabel>{label}</FieldLabel>}
      <textarea value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder} rows={rows}
        style={{ ...inputBaseStyle, resize: 'vertical' }} onFocus={onFocusStyle} onBlur={onBlurStyle} />
    </div>
  );
}

// ── Tabs ──────────────────────────────────────────────────────

function Tabs({ tabs, active, onChange }) {
  return (
    <div style={{ display: 'flex', borderBottom: '1px solid var(--border-soft)', marginBottom: '20px', gap: 0 }}>
      {tabs.map(t => (
        <button key={t} onClick={() => onChange(t)} style={{
          background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
          padding: '10px 16px', fontSize: '13px', fontWeight: active === t ? 500 : 400,
          color: active === t ? 'var(--text-primary)' : 'var(--text-muted)',
          borderBottom: active === t ? '2px solid #1F4D3D' : '2px solid transparent',
          marginBottom: '-1px', transition: '120ms ease',
        }}>{t}</button>
      ))}
    </div>
  );
}

// ── Chip ──────────────────────────────────────────────────────

function Chip({ label, active, onClick, count }) {
  return (
    <button onClick={onClick} style={{
      background: active ? 'rgba(31,77,61,0.15)' : 'var(--bg-surface-muted)',
      border: active ? '1px solid rgba(31,77,61,0.4)' : '1px solid var(--border-default)',
      color: active ? '#4A9B7E' : 'var(--text-secondary)',
      borderRadius: '4px', padding: '4px 12px', fontSize: '12px', fontWeight: 500,
      cursor: 'pointer', transition: '120ms ease', fontFamily: 'inherit',
    }}>
      {label}
      {count != null && (
        <span style={{ marginLeft: '6px', fontWeight: 700, color: active ? '#4A9B7E' : 'var(--text-muted)' }}>{count}</span>
      )}
    </button>
  );
}

// ── EmptyState ────────────────────────────────────────────────

function EmptyState({ message, subtle }) {
  return (
    <div style={{ padding: subtle ? '24px' : '40px 16px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>
      {message}
    </div>
  );
}

// ── SectionHeader ─────────────────────────────────────────────

function SectionHeader({ title, subtitle, action }) {
  return (
    <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '24px' }}>
      <div>
        <h1 style={{ margin: 0, fontSize: '22px', fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em' }}>{title}</h1>
        {subtitle && <p style={{ margin: '4px 0 0', fontSize: '13px', color: 'var(--text-muted)' }}>{subtitle}</p>}
      </div>
      {action}
    </div>
  );
}

// ── Row hover helper (table rows) ─────────────────────────────

function useRowHover() {
  const [hov, setHov] = useState(false);
  return { hov, onMouseEnter: () => setHov(true), onMouseLeave: () => setHov(false) };
}

Object.assign(window, {
  Icon, Badge, Avatar, Button, Modal, Drawer, Input, TMSelect, Textarea,
  Tabs, Chip, EmptyState, SectionHeader, useRowHover, getStatusColor,
  SAMPLE_STUDENTS, SAMPLE_PARENTS, SAMPLE_STAFF, SAMPLE_CLASSES,
  SAMPLE_REQUESTS, SAMPLE_SELF_TASKS, SAMPLE_LESSONS_PRINT, SAMPLE_INVOICES,
  // Invoicing engine, data + helpers
  INVOICE_SETTINGS, SAMPLE_DISCOUNT_TYPES, SAMPLE_CREDIT_CLAIMS, INVOICE_TODAY, SAMPLE_HOLIDAY_PROGRAMS,
  customerNo, lessonHours, termWeeks, lessonDateInWeek, defaultBillingTerm,
  computeBilledSpan, effectiveDiscounts, applyDiscounts, computeInvoiceAmounts,
  nextInvoiceNumber, invoiceOverdueInfo, addDays, toISODate, fmtAUDate, fmtAUD,
  COURSE_HOURS, expectedCourseHours, amountPaid, invoiceBalance, paymentStatus, recordPayment,
  creditNotesTotal, refundsTotal, nextCreditNoteNumber, issueCreditNote, writeOffInvoice, reverseWriteOff, refundInvoice, mondayOf,
  dishonourFeesTotal, termEndDate, addCadence, planInstalments, planCadenceOptions, setPaymentPlan, cancelPlan, dishonourInstalment,
  reminderState, reminderSchedule, lineAmount, newLineId, classLine, holidayLine, privateTermLine, computeInvoiceFromLines,
  buildInvoiceRecord, invoiceCourses, invoiceCoursesLabel, invoiceWeeksLabel,
  invoicePrimaryStudent, invoiceBillTo, invoiceCoversStudent, invoiceRecipients,
  INVOICE_COURSES, courseRate, enrolmentFeeLine, studentAttendedWeek,
  creditClaimAlreadyThisTerm, CLAIM_REJECT_REASONS,
});
