Add Visa Tracker Pro features to user dashboard

9 new tools modelled on Visa Tracker Pro (iOS):
- Points Calculator: full 189/190/491 points test with live breakdown
- Visa Case Timeline: 7-stage tracker (Skills Assess → EOI → Invite → Lodge → Grant) with date + notes
- Document Expiry Tracker: passport/visa/English test/assessment with colour-coded countdown
- VAC Fee Calculator: all major subclasses, primary + partner + children, live JS calc
- Occupation Search: 55 MLTSSL/STSOL occupations with ANZSCO, authority, visa eligibility
- State Nomination Criteria: all 8 states/territories, SC 190 + 491 min points + notes
- Processing Times: P25/P50/P75/P90 percentile table for 11 visa subclasses
- English Requirements: IELTS/PTE/TOEFL/OET table for 6 visa types
- Student Fund Calculator: DHA 2025-26 living costs + tuition + partner/children, live JS calc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
King Omar 2026-07-10 13:25:37 +10:00
parent 775e77ebe1
commit c3fa7fc96c
4 changed files with 1125 additions and 2 deletions

View file

@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS visa_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
occupation_anzsco TEXT,
occupation_name TEXT,
visa_subclass TEXT,
age INTEGER,
english_level TEXT,
education_level TEXT,
aus_study_years REAL,
professional_year INTEGER DEFAULT 0,
overseas_work_years REAL,
aus_work_years REAL,
partner_skills INTEGER DEFAULT 0,
naati INTEGER DEFAULT 0,
regional_study INTEGER DEFAULT 0,
state_nomination TEXT,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS case_timelines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
stage TEXT NOT NULL,
milestone_date TEXT,
notes TEXT,
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS document_expiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
doc_type TEXT NOT NULL,
doc_label TEXT NOT NULL,
expiry_date TEXT NOT NULL,
reminder_days INTEGER DEFAULT 180,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_visa_profiles_user ON visa_profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_timelines_user ON case_timelines(user_id);
CREATE INDEX IF NOT EXISTS idx_docs_user ON document_expiries(user_id);
CREATE INDEX IF NOT EXISTS idx_docs_expiry ON document_expiries(expiry_date);

View file

@ -4,8 +4,12 @@ import { searchCricos } from './cricos.js';
import { pushLeadToKondesk } from './kondesk.js';
import {
homePage, coursesPage, registerPage, loginPage,
dashboardPage, contactPage, healthInsurancePage, servicesPage, esc
dashboardPage, contactPage, healthInsurancePage, servicesPage, layout, esc
} from './templates.js';
import {
pointsPage, timelinePage, documentsPage, feesPage,
occupationsPage, stateCriteriaPage, processingTimesPage, englishPage, studentFundPage
} from './visa-tracker.js';
const app = new Hono();
@ -123,10 +127,140 @@ app.get('/dashboard/save', async c => {
return c.redirect('/dashboard');
});
// ── VISA TRACKER: POINTS CALCULATOR ─────────────────────────────────────────
app.get('/dashboard/points', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/points');
const profile = await c.env.DB.prepare('SELECT * FROM visa_profiles WHERE user_id=?').bind(user.id).first();
return c.html(layout('Points Calculator', pointsPage(user, profile, null), user));
});
app.post('/dashboard/points', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login');
const form = await c.req.formData();
const p = {
occupation_anzsco: form.get('occupation_anzsco')||'',
occupation_name: form.get('occupation_name')||'',
visa_subclass: form.get('visa_subclass')||'',
age: parseInt(form.get('age'))||0,
english_level: form.get('english_level')||'',
education_level: form.get('education_level')||'',
aus_study_years: form.get('aus_study_years')||'none',
professional_year: form.get('professional_year')==='1'?1:0,
overseas_work_years: form.get('overseas_work_years')||'lt3',
aus_work_years: form.get('aus_work_years')||'lt1',
partner_skills: parseInt(form.get('partner_skills'))||0,
naati: form.get('naati')==='1'?1:0,
regional_study: form.get('regional_study')==='1'?1:0,
state_nomination: form.get('state_nomination')||'',
};
await c.env.DB.prepare(`INSERT INTO visa_profiles (user_id,occupation_anzsco,occupation_name,visa_subclass,age,english_level,education_level,aus_study_years,professional_year,overseas_work_years,aus_work_years,partner_skills,naati,regional_study,state_nomination,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET occupation_anzsco=excluded.occupation_anzsco,occupation_name=excluded.occupation_name,visa_subclass=excluded.visa_subclass,age=excluded.age,english_level=excluded.english_level,education_level=excluded.education_level,aus_study_years=excluded.aus_study_years,professional_year=excluded.professional_year,overseas_work_years=excluded.overseas_work_years,aus_work_years=excluded.aus_work_years,partner_skills=excluded.partner_skills,naati=excluded.naati,regional_study=excluded.regional_study,state_nomination=excluded.state_nomination,updated_at=datetime('now')`)
.bind(user.id,p.occupation_anzsco,p.occupation_name,p.visa_subclass,p.age,p.english_level,p.education_level,p.aus_study_years,p.professional_year,p.overseas_work_years,p.aus_work_years,p.partner_skills,p.naati,p.regional_study,p.state_nomination).run();
return c.html(layout('Points Calculator', pointsPage(user, p, 'Points saved!'), user));
});
// ── VISA TRACKER: TIMELINE ───────────────────────────────────────────────────
app.get('/dashboard/timeline', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/timeline');
const { results } = await c.env.DB.prepare('SELECT * FROM case_timelines WHERE user_id=?').bind(user.id).all();
return c.html(layout('Visa Timeline', timelinePage(user, results, null), user));
});
app.post('/dashboard/timeline', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login');
const form = await c.req.formData();
const { TIMELINE_STAGES } = await import('./visa-data.js');
for (const stage of TIMELINE_STAGES) {
const date = form.get(`date_${stage.key}`) || null;
const notes = form.get(`notes_${stage.key}`) || null;
if (date || notes) {
await c.env.DB.prepare(`INSERT INTO case_timelines (user_id,stage,milestone_date,notes,updated_at) VALUES (?,?,?,?,datetime('now'))
ON CONFLICT(user_id,stage) DO UPDATE SET milestone_date=excluded.milestone_date,notes=excluded.notes,updated_at=datetime('now')`)
.bind(user.id, stage.key, date||null, notes||null).run().catch(()=>{});
}
}
const { results } = await c.env.DB.prepare('SELECT * FROM case_timelines WHERE user_id=?').bind(user.id).all();
return c.html(layout('Visa Timeline', timelinePage(user, results, 'Timeline saved!'), user));
});
// ── VISA TRACKER: DOCUMENT EXPIRY ────────────────────────────────────────────
app.get('/dashboard/documents', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/documents');
const { results } = await c.env.DB.prepare('SELECT * FROM document_expiries WHERE user_id=? ORDER BY expiry_date ASC').bind(user.id).all();
return c.html(layout('Document Expiry', documentsPage(user, results, null), user));
});
app.post('/dashboard/documents', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login');
const form = await c.req.formData();
const preset = form.get('doc_label') || '';
const custom = (form.get('custom_label') || '').trim();
const label = custom || preset;
const expiry = form.get('expiry_date') || '';
if (label && expiry) {
await c.env.DB.prepare('INSERT INTO document_expiries (user_id,doc_type,doc_label,expiry_date) VALUES (?,?,?,?)')
.bind(user.id, preset, label, expiry).run();
}
return c.redirect('/dashboard/documents');
});
app.post('/dashboard/documents/delete', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login');
const form = await c.req.formData();
const id = parseInt(form.get('id'));
if (id) await c.env.DB.prepare('DELETE FROM document_expiries WHERE id=? AND user_id=?').bind(id, user.id).run();
return c.redirect('/dashboard/documents');
});
// ── VISA TRACKER: STATIC TOOLS ───────────────────────────────────────────────
app.get('/dashboard/fees', c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/fees');
return c.html(layout('VAC Fee Calculator', feesPage(user), user));
});
app.get('/dashboard/occupations', c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/occupations');
return c.html(layout('Occupation Search', occupationsPage(user, c.req.query('q')||''), user));
});
app.get('/dashboard/state-criteria', c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/state-criteria');
return c.html(layout('State Nomination Criteria', stateCriteriaPage(user), user));
});
app.get('/dashboard/processing-times', c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/processing-times');
return c.html(layout('Processing Times', processingTimesPage(user), user));
});
app.get('/dashboard/english', c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/english');
return c.html(layout('English Requirements', englishPage(user), user));
});
app.get('/dashboard/student-fund', c => {
const user = c.get('user');
if (!user) return c.redirect('/login?redirect=/dashboard/student-fund');
return c.html(layout('Student Fund Calculator', studentFundPage(user), user));
});
// ── PROFILE ──────────────────────────────────────────────────────────────────
app.get('/dashboard/profile', async c => {
const user = c.get('user');
if (!user) return c.redirect('/login');
const { layout, esc } = await import('./templates.js');
return c.html(layout('My Profile', `
<section style="padding:40px 24px">
<div class="container" style="max-width:560px">

326
src/visa-data.js Normal file
View file

@ -0,0 +1,326 @@
// ── POINTS CALCULATOR ───────────────────────────────────────────────────────
export const POINTS_TABLE = {
age: [
{ label: '1824', min: 18, max: 24, points: 25 },
{ label: '2532', min: 25, max: 32, points: 30 },
{ label: '3339', min: 33, max: 39, points: 25 },
{ label: '4044', min: 40, max: 44, points: 15 },
{ label: '45+', min: 45, max: 99, points: 0 },
],
english: [
{ label: 'Competent (IELTS 6 / PTE 50)', value: 'competent', points: 0 },
{ label: 'Proficient (IELTS 7 / PTE 65)', value: 'proficient', points: 10 },
{ label: 'Superior (IELTS 8 / PTE 79)', value: 'superior', points: 20 },
],
education: [
{ label: 'Doctorate (PhD)', value: 'phd', points: 20 },
{ label: 'Bachelor / Masters / Diploma', value: 'bachelor', points: 15 },
{ label: 'None of the above', value: 'none', points: 0 },
],
ausStudy: [
{ label: 'No Australian study', value: 'none', points: 0 },
{ label: '12 years in Australia', value: '1-2', points: 5 },
{ label: '2+ years in Australia', value: '2+', points: 5 },
],
professionalYear: { points: 5, label: 'Completed Professional Year program (+5)' },
overseasWork: [
{ label: 'Less than 3 years', value: 'lt3', points: 0 },
{ label: '34 years', value: '3-4', points: 5 },
{ label: '57 years', value: '5-7', points: 10 },
{ label: '8+ years', value: '8+', points: 15 },
],
ausWork: [
{ label: 'Less than 1 year', value: 'lt1', points: 0 },
{ label: '12 years', value: '1-2', points: 5 },
{ label: '34 years', value: '3-4', points: 10 },
{ label: '57 years', value: '5-7', points: 15 },
{ label: '8+ years', value: '8+', points: 20 },
],
partnerSkills: { points: 10, label: 'Partner has competent English + nominated occupation / AQF Cert IV or above (+10)' },
singleOrAusCitizen: { points: 10, label: 'Single / partner is Australian citizen or PR (+10)' },
naati: { points: 5, label: 'NAATI community language credential (+5)' },
nomination190: { points: 5, label: 'State/Territory nomination — Subclass 190 (+5)' },
nomination491: { points: 15, label: 'State/Territory nomination — Subclass 491 (+15)' },
regionalStudy: { points: 5, label: 'Study in regional Australia (+5)' },
};
export function calcPoints(profile) {
let pts = 0;
const breakdown = [];
// Age
const age = parseInt(profile.age) || 0;
const ageBand = POINTS_TABLE.age.find(b => age >= b.min && age <= b.max);
if (ageBand) { pts += ageBand.points; breakdown.push({ label: `Age (${ageBand.label})`, points: ageBand.points }); }
// English
const eng = POINTS_TABLE.english.find(e => e.value === profile.english_level);
if (eng) { pts += eng.points; breakdown.push({ label: `English (${eng.label})`, points: eng.points }); }
// Education
const edu = POINTS_TABLE.education.find(e => e.value === profile.education_level);
if (edu) { pts += edu.points; breakdown.push({ label: `Education (${edu.label})`, points: edu.points }); }
// Australian study
const ausStudy = POINTS_TABLE.ausStudy.find(e => e.value === profile.aus_study_years);
if (ausStudy && ausStudy.points) { pts += ausStudy.points; breakdown.push({ label: `Australian Study (${ausStudy.label})`, points: ausStudy.points }); }
// Professional year
if (profile.professional_year == 1) { pts += 5; breakdown.push({ label: 'Professional Year', points: 5 }); }
// Overseas work
const owork = POINTS_TABLE.overseasWork.find(e => e.value === profile.overseas_work_years);
if (owork && owork.points) { pts += owork.points; breakdown.push({ label: `Overseas Work (${owork.label})`, points: owork.points }); }
// Australian work
const awork = POINTS_TABLE.ausWork.find(e => e.value === profile.aus_work_years);
if (awork && awork.points) { pts += awork.points; breakdown.push({ label: `Australian Work (${awork.label})`, points: awork.points }); }
// Partner / single
if (profile.partner_skills == 1) { pts += 10; breakdown.push({ label: 'Partner Skills', points: 10 }); }
else if (profile.partner_skills == 2) { pts += 10; breakdown.push({ label: 'Single / AUS Citizen Partner', points: 10 }); }
// NAATI
if (profile.naati == 1) { pts += 5; breakdown.push({ label: 'NAATI Community Language', points: 5 }); }
// Regional study
if (profile.regional_study == 1) { pts += 5; breakdown.push({ label: 'Regional Australian Study', points: 5 }); }
// State nomination
if (profile.state_nomination === '190') { pts += 5; breakdown.push({ label: 'State Nomination (190)', points: 5 }); }
else if (profile.state_nomination === '491') { pts += 15; breakdown.push({ label: 'State Nomination (491)', points: 15 }); }
return { total: pts, breakdown };
}
// ── VAC FEES ─────────────────────────────────────────────────────────────────
export const VAC_FEES = [
{ subclass: '189', name: 'Skilled Independent', primary: 4770, secondary: 2385, child: 1195 },
{ subclass: '190', name: 'Skilled Nominated', primary: 4770, secondary: 2385, child: 1195 },
{ subclass: '491', name: 'Skilled Work Regional', primary: 4770, secondary: 2385, child: 1195 },
{ subclass: '186', name: 'Employer Nomination (ENS)', primary: 4770, secondary: 2385, child: 1195 },
{ subclass: '187', name: 'Regional Sponsored (RSMS)', primary: 4770, secondary: 2385, child: 1195 },
{ subclass: '482', name: 'Temporary Skill Shortage', primary: 3115, secondary: 1560, child: 1560 },
{ subclass: '494', name: 'Skilled Employer Sponsored Regional', primary: 4770, secondary: 2385, child: 1195 },
{ subclass: '500', name: 'Student Visa', primary: 710, secondary: 355, child: 355 },
{ subclass: '485', name: 'Graduate Temporary', primary: 1895, secondary: 950, child: 475 },
{ subclass: '820', name: 'Partner (onshore)', primary: 8850, secondary: 4425, child: 0 },
{ subclass: '801', name: 'Partner (permanent onshore)',primary: 0, secondary: 0, child: 0, note: 'No additional fee if applying with 820' },
{ subclass: '309', name: 'Partner (offshore)', primary: 8850, secondary: 4425, child: 0 },
{ subclass: '100', name: 'Partner (permanent offshore)',primary: 0, secondary: 0, child: 0, note: 'No additional fee if applying with 309' },
{ subclass: '600', name: 'Visitor Visa', primary: 190, secondary: 190, child: 190 },
{ subclass: '407', name: 'Training Visa', primary: 335, secondary: 170, child: 170 },
];
// ── PROCESSING TIMES ──────────────────────────────────────────────────────────
export const PROCESSING_TIMES = [
{ subclass: '189', name: 'Skilled Independent', p25: 12, p50: 20, p75: 30, p90: 48, note: 'Invitation required via SkillSelect EOI' },
{ subclass: '190', name: 'Skilled Nominated', p25: 8, p50: 14, p75: 22, p90: 36, note: 'State nomination required first' },
{ subclass: '491', name: 'Skilled Work Regional',p25: 8, p50: 14, p75: 22, p90: 36, note: 'State/family nomination required' },
{ subclass: '482', name: 'TSS (employer)', p25: 1, p50: 3, p75: 5, p90: 9, note: 'Short stream approx.' },
{ subclass: '186', name: 'ENS (employer nom.)', p25: 8, p50: 14, p75: 24, p90: 36, note: 'Direct entry stream' },
{ subclass: '494', name: 'Employer Sponsored Regional', p25: 8, p50: 14, p75: 22, p90: 36, note: '' },
{ subclass: '500', name: 'Student Visa', p25: 1, p50: 2, p75: 3, p90: 5, note: 'Varies by nationality and provider' },
{ subclass: '485', name: 'Graduate Temporary', p25: 2, p50: 4, p75: 7, p90: 12, note: '2-year or 4-year stream' },
{ subclass: '820', name: 'Partner (onshore)', p25: 12, p50: 24, p75: 36, p90: 48, note: 'Bridging visa granted while waiting' },
{ subclass: '309', name: 'Partner (offshore)', p25: 12, p50: 24, p75: 36, p90: 48, note: '' },
{ subclass: '600', name: 'Visitor Visa', p25: 1, p50: 1, p75: 2, p90: 3, note: 'Online applications' },
];
// ── STATE NOMINATION CRITERIA ─────────────────────────────────────────────────
export const STATE_CRITERIA = [
{
state: 'NSW', fullName: 'New South Wales',
sc190: { minPoints: 90, notes: 'Must have employment offer or strong ties to NSW. High demand occupations only.' },
sc491: { minPoints: 85, notes: 'Regional NSW only. Must live/work in designated regional areas.' },
website: 'https://www.nsw.gov.au/working-in-nsw/visa-and-migration',
},
{
state: 'VIC', fullName: 'Victoria',
sc190: { minPoints: 90, notes: 'Expression of Interest submitted via Victoria\'s Skilled Visa portal. Occupations from Victorian list.' },
sc491: { minPoints: 85, notes: 'Must live and work in regional Victoria.' },
website: 'https://business.vic.gov.au/visa-and-migration',
},
{
state: 'QLD', fullName: 'Queensland',
sc190: { minPoints: 85, notes: 'QLD Skills in Demand list. Must intend to live and work in QLD.' },
sc491: { minPoints: 80, notes: 'Regional QLD. Must live in designated regions.' },
website: 'https://migration.qld.gov.au/',
},
{
state: 'WA', fullName: 'Western Australia',
sc190: { minPoints: 80, notes: 'WA Skilled Migration Occupation List. Strong preference for in-demand trades/healthcare.' },
sc491: { minPoints: 75, notes: 'Regional WA. Must reside in regional WA.' },
website: 'https://www.wa.gov.au/service/employment/migration/migrate-western-australia',
},
{
state: 'SA', fullName: 'South Australia',
sc190: { minPoints: 75, notes: 'SA Critical Skills List. Lower threshold but strong occupation requirement.' },
sc491: { minPoints: 65, notes: 'Regional SA. Must live and work in SA.' },
website: 'https://www.migration.sa.gov.au/',
},
{
state: 'TAS', fullName: 'Tasmania',
sc190: { minPoints: 65, notes: 'Open to most eligible occupations. Must intend to live and work in Tasmania.' },
sc491: { minPoints: 60, notes: 'All of Tasmania is classified as regional.' },
website: 'https://www.migration.tas.gov.au/',
},
{
state: 'ACT', fullName: 'Australian Capital Territory',
sc190: { minPoints: 90, notes: 'ACT Critical Skills List. Must have an ACT-based job offer in most cases.' },
sc491: { minPoints: null, notes: 'ACT does not participate in SC 491 (not a regional state).' },
website: 'https://www.act.gov.au/migration',
},
{
state: 'NT', fullName: 'Northern Territory',
sc190: { minPoints: 65, notes: 'NT occupation list. Lower points threshold. Strong demand for trades, healthcare, agriculture.' },
sc491: { minPoints: 60, notes: 'All of NT is classified as regional.' },
website: 'https://migration.nt.gov.au/',
},
];
// ── ENGLISH REQUIREMENTS ──────────────────────────────────────────────────────
export const ENGLISH_REQUIREMENTS = [
{
visa: '189 / 190 / 491',
name: 'Skilled Migration',
level: 'Competent English (minimum)',
ielts: 'L6 / R6 / W6 / S6',
pte: 'L50 / R50 / W50 / S50',
toefl: 'L12 / R13 / W21 / S18',
oet: 'B in all components',
notes: 'Proficient (IELTS 7) earns +10 pts; Superior (IELTS 8) earns +20 pts',
},
{
visa: '482 (TSS)',
name: 'Temporary Skill Shortage',
level: 'Competent English',
ielts: 'L4.5 / R4.5 / W4.5 / S4.5 (avg 5.0)',
pte: '36 in each component (avg 42)',
toefl: 'L3 / R3 / W14 / S12 (total 24)',
oet: 'B in each component',
notes: 'Some exemptions for certain nationalities and assessment pathways',
},
{
visa: '186 (ENS)',
name: 'Employer Nomination',
level: 'Competent English',
ielts: 'L6 / R6 / W6 / S6',
pte: 'L50 / R50 / W50 / S50',
toefl: 'L12 / R13 / W21 / S18',
oet: 'B in all components',
notes: 'Direct entry stream',
},
{
visa: '500',
name: 'Student Visa',
level: 'Provider-dependent',
ielts: 'Typically 5.56.5 overall',
pte: 'Typically 4258',
toefl: 'Typically 4679',
oet: 'Typically C+ in all',
notes: 'Each institution sets its own minimum; confirm with your provider',
},
{
visa: '485',
name: 'Graduate Temporary',
level: 'Competent English',
ielts: 'L6 / R6 / W6 / S6',
pte: 'L50 / R50 / W50 / S50',
toefl: 'L12 / R13 / W21 / S18',
oet: 'B in all components',
notes: 'Must have met English requirement for the SC 500 student visa',
},
{
visa: '820 / 801 / 309 / 100',
name: 'Partner Visa',
level: 'No minimum required',
ielts: 'N/A',
pte: 'N/A',
toefl: 'N/A',
oet: 'N/A',
notes: 'English is not mandatory for partner visas; affects some citizenship pathways',
},
];
// ── STUDENT FUND CALCULATOR ───────────────────────────────────────────────────
export const STUDENT_FUND = {
livingCostPerYear: 29710, // AUD (2025 DHA figure)
partnerPerYear: 10394,
childPerYear: 4449,
note: 'Based on DHA 2024-25 financial capacity requirements. Does not include tuition fees, travel, or health cover.',
};
// ── OCCUPATIONS (MLTSSL + key STSOL) ─────────────────────────────────────────
export const OCCUPATIONS = [
// ICT
{ anzsco: '261111', title: 'ICT Business Analyst', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '261112', title: 'Systems Analyst', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '261311', title: 'Analyst Programmer', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '261312', title: 'Developer Programmer', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '261313', title: 'Software Engineer', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '261314', title: 'Software Tester', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '261399', title: 'Software/Apps Programmers NEC', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '262111', title: 'Database Administrator', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '262112', title: 'ICT Security Specialist', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '263111', title: 'Computer Network Engineer', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '263112', title: 'Network Administrator', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '263211', title: 'ICT Quality Assurance Engineer', list: 'MLTSSL', authority: 'ACS', visas: ['189','190','491','482','186'] },
{ anzsco: '263311', title: 'Telecommunications Engineer', list: 'MLTSSL', authority: 'ANZSCO', visas: ['189','190','491','482','186'] },
// Nursing / Healthcare
{ anzsco: '254111', title: 'Occupational Therapist', list: 'MLTSSL', authority: 'AOTC', visas: ['189','190','491','482','186'] },
{ anzsco: '252411', title: 'Physiotherapist', list: 'MLTSSL', authority: 'APC', visas: ['189','190','491','482','186'] },
{ anzsco: '252511', title: 'Podiatrist', list: 'MLTSSL', authority: 'PODAC', visas: ['189','190','491','482','186'] },
{ anzsco: '252711', title: 'Radiographer', list: 'MLTSSL', authority: 'AIR', visas: ['189','190','491','482','186'] },
{ anzsco: '252211', title: 'Medical Laboratory Scientist', list: 'MLTSSL', authority: 'AIMS', visas: ['189','190','491','482','186'] },
{ anzsco: '272311', title: 'Registered Nurse (Medical)', list: 'MLTSSL', authority: 'ANMAC', visas: ['189','190','491','482','186'] },
{ anzsco: '272312', title: 'Registered Nurse (Surgical)', list: 'MLTSSL', authority: 'ANMAC', visas: ['189','190','491','482','186'] },
{ anzsco: '272317', title: 'Registered Nurse (Mental Health)', list: 'MLTSSL', authority: 'ANMAC', visas: ['189','190','491','482','186'] },
{ anzsco: '272399', title: 'Registered Nurses NEC', list: 'MLTSSL', authority: 'ANMAC', visas: ['189','190','491','482','186'] },
{ anzsco: '251211', title: 'Medical Practitioner (General)', list: 'MLTSSL', authority: 'AMC', visas: ['189','190','491','482','186'] },
{ anzsco: '253111', title: 'General Practitioner', list: 'MLTSSL', authority: 'AMC', visas: ['189','190','491','482','186'] },
{ anzsco: '253321', title: 'Psychiatrist', list: 'MLTSSL', authority: 'AMC', visas: ['189','190','491','482','186'] },
{ anzsco: '253999', title: 'Medical Specialists NEC', list: 'MLTSSL', authority: 'AMC', visas: ['189','190','491','482','186'] },
// Engineering
{ anzsco: '233111', title: 'Chemical Engineer', list: 'MLTSSL', authority: 'Engineers Australia', visas: ['189','190','491','482','186'] },
{ anzsco: '233211', title: 'Civil Engineer', list: 'MLTSSL', authority: 'Engineers Australia', visas: ['189','190','491','482','186'] },
{ anzsco: '233214', title: 'Structural Engineer', list: 'MLTSSL', authority: 'Engineers Australia', visas: ['189','190','491','482','186'] },
{ anzsco: '233512', title: 'Mechanical Engineer', list: 'MLTSSL', authority: 'Engineers Australia', visas: ['189','190','491','482','186'] },
{ anzsco: '233611', title: 'Mining Engineer', list: 'MLTSSL', authority: 'Engineers Australia', visas: ['189','190','491','482','186'] },
{ anzsco: '233999', title: 'Engineering Professionals NEC', list: 'MLTSSL', authority: 'Engineers Australia', visas: ['189','190','491','482','186'] },
{ anzsco: '232111', title: 'Architect', list: 'MLTSSL', authority: 'AACA', visas: ['189','190','491','482','186'] },
{ anzsco: '232212', title: 'Landscape Architect', list: 'MLTSSL', authority: 'AILA', visas: ['189','190','491','482','186'] },
// Accounting / Finance
{ anzsco: '221111', title: 'Accountant (General)', list: 'MLTSSL', authority: 'CPAA/CAANZ/IPA', visas: ['189','190','491','482','186'] },
{ anzsco: '221112', title: 'Management Accountant', list: 'MLTSSL', authority: 'CPAA/CAANZ/IPA', visas: ['189','190','491','482','186'] },
{ anzsco: '221113', title: 'Taxation Accountant', list: 'MLTSSL', authority: 'CPAA/CAANZ/IPA', visas: ['189','190','491','482','186'] },
{ anzsco: '221114', title: 'External Auditor', list: 'MLTSSL', authority: 'CPAA/CAANZ', visas: ['189','190','491','482','186'] },
{ anzsco: '222111', title: 'Financial Investment Adviser', list: 'STSOL', authority: 'FINSIA', visas: ['190','491','482'] },
// Education
{ anzsco: '241111', title: 'Early Childhood Teacher', list: 'MLTSSL', authority: 'ACECQA', visas: ['189','190','491','482','186'] },
{ anzsco: '241411', title: 'Special Education Teacher', list: 'MLTSSL', authority: 'AITSL', visas: ['189','190','491','482','186'] },
{ anzsco: '241213', title: 'Secondary School Teacher', list: 'STSOL', authority: 'AITSL', visas: ['190','491','482'] },
// Trades
{ anzsco: '323211', title: 'Fitter (general)', list: 'MLTSSL', authority: 'TRA', visas: ['189','190','491','482','186'] },
{ anzsco: '342111', title: 'Electrician (general)', list: 'MLTSSL', authority: 'TRA', visas: ['189','190','491','482','186'] },
{ anzsco: '334111', title: 'Plumber (general)', list: 'MLTSSL', authority: 'TRA', visas: ['189','190','491','482','186'] },
{ anzsco: '331112', title: 'Bricklayer', list: 'MLTSSL', authority: 'TRA', visas: ['189','190','491','482','186'] },
{ anzsco: '331211', title: 'Carpenter and Joiner', list: 'MLTSSL', authority: 'TRA', visas: ['189','190','491','482','186'] },
{ anzsco: '333111', title: 'Painting Tradesperson', list: 'MLTSSL', authority: 'TRA', visas: ['189','190','491','482','186'] },
{ anzsco: '361111', title: 'Veterinarian', list: 'MLTSSL', authority: 'AVA', visas: ['189','190','491','482','186'] },
{ anzsco: '251411', title: 'Optometrist', list: 'MLTSSL', authority: 'OCANZ', visas: ['189','190','491','482','186'] },
{ anzsco: '251611', title: 'Dental Therapist', list: 'MLTSSL', authority: 'ADC', visas: ['189','190','491','482','186'] },
{ anzsco: '252311', title: 'Dental Specialist', list: 'MLTSSL', authority: 'ADC', visas: ['189','190','491','482','186'] },
{ anzsco: '271311', title: 'Social Worker', list: 'MLTSSL', authority: 'AASW', visas: ['189','190','491','482','186'] },
{ anzsco: '272111', title: 'Counsellor', list: 'STSOL', authority: 'VETASSESS',visas: ['190','491','482'] },
];
export const TIMELINE_STAGES = [
{ key: 'skill_assessment', label: 'Skills Assessment', icon: '📋', desc: 'Skills assessed by relevant authority (TRA, ACS, ANMAC, Engineers Australia, etc.)' },
{ key: 'english_test', label: 'English Test', icon: '🗣️', desc: 'IELTS, PTE, TOEFL, or OET completed' },
{ key: 'eoi_lodged', label: 'EOI Submitted', icon: '📝', desc: 'Expression of Interest submitted in SkillSelect' },
{ key: 'invitation', label: 'Invitation Received', icon: '✉️', desc: 'Invitation to Apply (ITA) received from DHA or state authority' },
{ key: 'visa_lodged', label: 'Visa Application Lodged', icon: '🚀', desc: 'Visa application submitted to the Department of Home Affairs' },
{ key: 'decision', label: 'Decision Made', icon: '⚖️', desc: 'DHA has made a decision on the application' },
{ key: 'visa_granted', label: 'Visa Granted! 🎉', icon: '🎉', desc: 'Visa approved — welcome to Australia!' },
];

617
src/visa-tracker.js Normal file
View file

@ -0,0 +1,617 @@
import { POINTS_TABLE, calcPoints, VAC_FEES, PROCESSING_TIMES, STATE_CRITERIA, ENGLISH_REQUIREMENTS, STUDENT_FUND, OCCUPATIONS, TIMELINE_STAGES } from './visa-data.js';
import { esc } from './templates.js';
const BASE_CSS = `
.vt-card{background:#fff;border-radius:12px;padding:28px;box-shadow:0 2px 8px rgba(0,0,0,.05);margin-bottom:24px}
.vt-card h3{font-size:1.1rem;font-weight:700;color:#1a2744;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid #e8f0fe}
.vt-section-title{font-size:1.6rem;font-weight:800;color:#1a2744;margin-bottom:6px}
.vt-section-sub{color:#64748b;margin-bottom:28px}
.pts-total{background:linear-gradient(135deg,#1a2744,#1a5bb8);color:#fff;border-radius:16px;padding:28px;text-align:center;margin-bottom:24px}
.pts-num{font-size:4rem;font-weight:900;line-height:1}
.pts-label{font-size:1rem;opacity:.85;margin-top:6px}
.pts-bar{height:8px;background:#e8f0fe;border-radius:4px;overflow:hidden;margin:6px 0 4px}
.pts-fill{height:100%;background:linear-gradient(90deg,#1a5bb8,#38bdf8);border-radius:4px;transition:.4s}
.breakdown-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #f1f5f9;font-size:.9rem}
.breakdown-row:last-child{border-bottom:none}
.breakdown-pts{font-weight:700;color:#1a5bb8}
.timeline-wrap{position:relative;padding-left:44px}
.timeline-wrap::before{content:'';position:absolute;left:18px;top:0;bottom:0;width:2px;background:#e8f0fe}
.tl-stage{position:relative;margin-bottom:24px}
.tl-dot{position:absolute;left:-44px;top:2px;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1rem;z-index:1}
.tl-dot-done{background:#1a5bb8;color:#fff}
.tl-dot-active{background:#f59e0b;color:#fff;box-shadow:0 0 0 4px rgba(245,158,11,.2)}
.tl-dot-pending{background:#f1f5f9;color:#94a3b8;border:2px solid #d1d9e8}
.tl-content{background:#fff;border-radius:10px;padding:16px;border:1px solid #e8f0fe}
.tl-content.done{border-color:#dbeafe;background:#f0f6ff}
.tl-content.active{border-color:#f59e0b;background:#fffbeb}
.tl-label{font-weight:700;color:#1a2744;font-size:.95rem}
.tl-date{font-size:.85rem;color:#1a5bb8;font-weight:600;margin-top:2px}
.tl-desc{font-size:.85rem;color:#64748b;margin-top:4px}
.doc-row{display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid #f1f5f9;gap:12px;flex-wrap:wrap}
.doc-row:last-child{border-bottom:none}
.doc-label{font-weight:600;font-size:.9rem;color:#1a2744}
.doc-expiry{font-size:.85rem;color:#64748b}
.expiry-ok{color:#16a34a;font-weight:600}
.expiry-warn{color:#d97706;font-weight:600}
.expiry-danger{color:#dc2626;font-weight:600}
.expiry-expired{color:#dc2626;font-weight:700}
.fee-total{font-size:1.6rem;font-weight:800;color:#1a5bb8}
.occ-row{padding:12px 16px;border-radius:8px;background:#fff;border:1px solid #e8f0fe;margin-bottom:8px}
.occ-title{font-weight:700;font-size:.95rem;color:#1a2744}
.occ-meta{font-size:.8rem;color:#64748b;margin-top:3px;display:flex;gap:10px;flex-wrap:wrap}
.list-mltssl{background:#dcfce7;color:#166534;padding:2px 8px;border-radius:10px;font-weight:600;font-size:.75rem}
.list-stsol{background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:10px;font-weight:600;font-size:.75rem}
.state-card{background:#fff;border-radius:10px;border:1px solid #e8f0fe;padding:18px;margin-bottom:12px}
.state-card h4{font-weight:700;color:#1a2744;margin-bottom:8px}
.proc-row{display:grid;grid-template-columns:1fr 80px 80px 80px 80px;gap:8px;padding:10px 0;border-bottom:1px solid #f1f5f9;font-size:.9rem;align-items:center}
.proc-row:last-child{border-bottom:none}
.proc-header{font-weight:700;color:#1a2744;font-size:.8rem;text-transform:uppercase;letter-spacing:.5px}
.p-val{text-align:center;font-weight:600;color:#1a5bb8}
.eng-table{width:100%;border-collapse:collapse;font-size:.88rem}
.eng-table th{background:#f0f6ff;color:#1a2744;font-weight:700;padding:10px 12px;text-align:left}
.eng-table td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:top}
.eng-table tr:last-child td{border-bottom:none}
.fund-result{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:20px;margin-top:16px}
.fund-total{font-size:1.8rem;font-weight:800;color:#16a34a}
@media(max-width:600px){.proc-row{grid-template-columns:1fr 60px 60px 60px;}.proc-row>*:last-child{display:none}}
`;
function dashWrap(user, activeTab, content) {
const initials = user.full_name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0,2);
const tabs = [
{ key: 'overview', href: '/dashboard', label: '📊 Overview' },
{ key: 'points', href: '/dashboard/points', label: '🔢 Points Calculator' },
{ key: 'timeline', href: '/dashboard/timeline', label: '📅 Visa Timeline' },
{ key: 'documents', href: '/dashboard/documents', label: '📄 Document Expiry' },
{ key: 'fees', href: '/dashboard/fees', label: '💰 VAC Fee Calculator' },
{ key: 'occupations', href: '/dashboard/occupations', label: '🔍 Occupation Search' },
{ key: 'state-criteria', href: '/dashboard/state-criteria', label: '🗺️ State Criteria' },
{ key: 'processing-times', href: '/dashboard/processing-times', label: '⏱️ Processing Times' },
{ key: 'english', href: '/dashboard/english', label: '🗣️ English Requirements' },
{ key: 'student-fund', href: '/dashboard/student-fund', label: '🎓 Student Fund Calc' },
{ key: 'courses', href: '/courses', label: '🎓 Search Courses' },
{ key: 'contact', href: '/contact', label: '📝 Book Consultation' },
{ key: 'profile', href: '/dashboard/profile', label: '👤 My Profile' },
];
return `
<style>${BASE_CSS}</style>
<section style="padding:32px 24px;background:#f8faff;min-height:85vh">
<div class="container">
<div style="display:grid;grid-template-columns:240px 1fr;gap:28px;align-items:start" class="dash-grid">
<div class="dash-sidebar">
<div class="dash-user">
<div class="dash-avatar">${esc(initials)}</div>
<div style="font-weight:700;color:#1a2744;font-size:.95rem">${esc(user.full_name)}</div>
<div style="font-size:.8rem;color:#94a3b8;margin-top:3px">${esc(user.email)}</div>
</div>
<ul class="dash-nav">
${tabs.map(t => `<li><a href="${t.href}" class="${activeTab===t.key?'active':''}" style="font-size:.88rem">${t.label}</a></li>`).join('')}
<li><a href="/logout" style="color:#dc2626;font-size:.88rem">🚪 Logout</a></li>
</ul>
</div>
<div>${content}</div>
</div>
</div>
</section>
<style>@media(max-width:768px){.dash-grid{grid-template-columns:1fr!important}}</style>`;
}
// ── POINTS CALCULATOR ───────────────────────────────────────────────────────
export function pointsPage(user, profile, flash) {
const result = profile ? calcPoints(profile) : null;
const total = result ? result.total : 0;
const pct = Math.min(100, (total / 100) * 100);
const statusColor = total >= 90 ? '#16a34a' : total >= 65 ? '#d97706' : '#dc2626';
const statusLabel = total >= 90 ? '✅ Competitive' : total >= 65 ? '⚠️ Getting there' : '🔴 Below typical threshold';
const selFor = (name, options, current) =>
`<select name="${name}" class="form-group-input" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem;background:#fafbff">
${options.map(o => `<option value="${esc(String(o.value||o))}"${String(current)===String(o.value||o)?' selected':''}>${esc(o.label||o)}</option>`).join('')}
</select>`;
const fg = (label, name, select) => `
<div class="form-group">
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px;color:#1a2744">${label}</label>
${select}
</div>`;
const p = profile || {};
const body = `
<div class="vt-section-title">🔢 Points Calculator</div>
<p class="vt-section-sub">Australian skilled migration points test (SC 189 / 190 / 491)</p>
${result ? `
<div class="pts-total">
<div class="pts-num">${total}</div>
<div class="pts-label">Total Points</div>
<div style="margin-top:12px;font-size:1rem;font-weight:700">${statusLabel}</div>
<div style="margin-top:8px;font-size:.85rem;opacity:.8">Typical invitation threshold: 6590+ (varies by occupation & round)</div>
</div>
<div class="vt-card">
<h3>Points Breakdown</h3>
${result.breakdown.map(b => `
<div class="breakdown-row">
<span>${esc(b.label)}</span>
<span class="breakdown-pts">+${b.points}</span>
</div>`).join('')}
<div class="breakdown-row" style="font-weight:700;font-size:1rem;border-top:2px solid #e8f0fe;margin-top:4px;padding-top:12px">
<span>Total</span><span style="color:#1a5bb8;font-size:1.1rem">+${total}</span>
</div>
<div style="margin-top:16px">
<div style="display:flex;justify-content:space-between;font-size:.8rem;color:#64748b;margin-bottom:4px"><span>0</span><span>65</span><span>90</span><span>100</span></div>
<div class="pts-bar"><div class="pts-fill" style="width:${pct}%"></div></div>
</div>
</div>` : ''}
<div class="vt-card">
<h3>${result ? 'Update Your Points Profile' : 'Enter Your Details'}</h3>
${flash ? `<div class="alert alert-success">${esc(flash)}</div>` : ''}
<form method="POST" action="/dashboard/points">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
${fg('Age', 'age', `<input type="number" name="age" value="${esc(String(p.age||''))}" min="18" max="70" placeholder="e.g. 28" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">`)}
${fg('English Level', 'english_level', selFor('english_level', POINTS_TABLE.english, p.english_level||''))}
${fg('Highest Education', 'education_level', selFor('education_level', POINTS_TABLE.education, p.education_level||''))}
${fg('Australian Study', 'aus_study_years', selFor('aus_study_years', POINTS_TABLE.ausStudy, p.aus_study_years||''))}
${fg('Overseas Work Experience', 'overseas_work_years', selFor('overseas_work_years', POINTS_TABLE.overseasWork, p.overseas_work_years||''))}
${fg('Australian Work Experience', 'aus_work_years', selFor('aus_work_years', POINTS_TABLE.ausWork, p.aus_work_years||''))}
${fg('State Nomination', 'state_nomination', selFor('state_nomination',
[{label:'None',value:''},{label:'State nomination — SC 190 (+5)',value:'190'},{label:'State nomination — SC 491 (+15)',value:'491'}],
p.state_nomination||''))}
${fg('Partner Status', 'partner_skills', selFor('partner_skills',
[{label:'Partner has skills + competent English (+10)',value:'1'},{label:'Single / partner is AUS citizen or PR (+10)',value:'2'},{label:'Partner (no points)',value:'0'}],
p.partner_skills||'0'))}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:8px">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;cursor:pointer">
<input type="checkbox" name="professional_year" value="1" ${p.professional_year==1?'checked':''}> Professional Year (+5)
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;cursor:pointer">
<input type="checkbox" name="naati" value="1" ${p.naati==1?'checked':''}> NAATI Credential (+5)
</label>
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;cursor:pointer">
<input type="checkbox" name="regional_study" value="1" ${p.regional_study==1?'checked':''}> Regional Study (+5)
</label>
</div>
<button type="submit" class="form-submit" style="margin-top:20px">Calculate Points</button>
</form>
</div>`;
return dashWrap(user, 'points', body);
}
// ── VISA TIMELINE ────────────────────────────────────────────────────────────
export function timelinePage(user, stages, flash) {
const stageMap = {};
(stages || []).forEach(s => { stageMap[s.stage] = s; });
const completedCount = TIMELINE_STAGES.filter(s => stageMap[s.key]?.milestone_date).length;
const activeIdx = TIMELINE_STAGES.findIndex(s => !stageMap[s.key]?.milestone_date);
const body = `
<div class="vt-section-title">📅 Visa Case Timeline</div>
<p class="vt-section-sub">Track your migration journey from skills assessment to visa grant</p>
${flash ? `<div class="alert alert-success">${esc(flash)}</div>` : ''}
<div class="vt-card" style="padding:16px 24px">
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<div>
<div style="font-size:.8rem;color:#64748b">Progress</div>
<div style="font-weight:700;font-size:1.1rem;color:#1a2744">${completedCount} / ${TIMELINE_STAGES.length} stages</div>
</div>
<div style="flex:1;min-width:200px">
<div class="pts-bar"><div class="pts-fill" style="width:${Math.round((completedCount/TIMELINE_STAGES.length)*100)}%"></div></div>
</div>
<div style="font-weight:700;color:#1a5bb8">${Math.round((completedCount/TIMELINE_STAGES.length)*100)}%</div>
</div>
</div>
<form method="POST" action="/dashboard/timeline">
<div class="timeline-wrap">
${TIMELINE_STAGES.map((stage, i) => {
const saved = stageMap[stage.key];
const isDone = !!saved?.milestone_date;
const isActive = !isDone && i === activeIdx;
const dotClass = isDone ? 'tl-dot-done' : isActive ? 'tl-dot-active' : 'tl-dot-pending';
const cardClass = isDone ? 'done' : isActive ? 'active' : '';
return `
<div class="tl-stage">
<div class="tl-dot ${dotClass}">${isDone ? '✓' : stage.icon}</div>
<div class="tl-content ${cardClass}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<div class="tl-label">${stage.label}</div>
<div class="tl-desc">${stage.desc}</div>
${saved?.milestone_date ? `<div class="tl-date">📅 ${new Date(saved.milestone_date).toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'})}</div>` : ''}
</div>
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0">
<input type="date" name="date_${esc(stage.key)}" value="${esc(saved?.milestone_date||'')}"
style="padding:6px 10px;border:1.5px solid #d1d9e8;border-radius:6px;font-size:.85rem">
${saved?.notes ? `<span style="font-size:.8rem;color:#64748b;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(saved.notes)}</span>` : ''}
</div>
</div>
<div style="margin-top:8px">
<input type="text" name="notes_${esc(stage.key)}" value="${esc(saved?.notes||'')}"
placeholder="Notes (optional)"
style="width:100%;padding:6px 10px;border:1.5px solid #d1d9e8;border-radius:6px;font-size:.82rem">
</div>
</div>
</div>`;
}).join('')}
</div>
<button type="submit" class="form-submit">Save Timeline</button>
</form>`;
return dashWrap(user, 'timeline', body);
}
// ── DOCUMENT EXPIRY ──────────────────────────────────────────────────────────
export function documentsPage(user, docs, flash) {
const today = new Date();
const sorted = [...(docs||[])].sort((a,b) => new Date(a.expiry_date) - new Date(b.expiry_date));
function expiryStatus(dateStr) {
const d = new Date(dateStr);
const days = Math.round((d - today) / 86400000);
if (days < 0) return { cls: 'expiry-expired', label: `Expired ${Math.abs(days)}d ago` };
if (days <= 30) return { cls: 'expiry-danger', label: `Expires in ${days}d` };
if (days <= 180) return { cls: 'expiry-warn', label: `Expires in ${days}d` };
return { cls: 'expiry-ok', label: `${days}d remaining` };
}
const presets = ['Passport','Current Visa','English Test (IELTS/PTE)','Skills Assessment','EOI Expiry','Health Insurance (OSHC)','Police Clearance','Medical Certificate','Employer Sponsorship','Other Document'];
const body = `
<div class="vt-section-title">📄 Document Expiry Tracker</div>
<p class="vt-section-sub">Monitor passport, visa, English test, and other critical document expiry dates</p>
${flash ? `<div class="alert alert-success">${esc(flash)}</div>` : ''}
${sorted.length > 0 ? `
<div class="vt-card">
<h3>Your Documents</h3>
${sorted.map(d => {
const { cls, label } = expiryStatus(d.expiry_date);
return `
<div class="doc-row">
<div>
<div class="doc-label">${esc(d.doc_label)}</div>
<div class="doc-expiry">${new Date(d.expiry_date).toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'})}</div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<span class="${cls}">${label}</span>
<form method="POST" action="/dashboard/documents/delete" style="display:inline">
<input type="hidden" name="id" value="${d.id}">
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;font-size:.85rem;padding:4px 8px;border-radius:4px;border:1px solid #fecaca">Delete</button>
</form>
</div>
</div>`}).join('')}
</div>` : ''}
<div class="vt-card">
<h3>Add Document</h3>
<form method="POST" action="/dashboard/documents">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div class="form-group">
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Document Type</label>
<select name="doc_label" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
${presets.map(p => `<option value="${esc(p)}">${esc(p)}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Expiry Date</label>
<input type="date" name="expiry_date" required style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
<div class="form-group">
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Custom Label (optional)</label>
<input type="text" name="custom_label" placeholder="e.g. Passport — Australian" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
<div style="display:flex;align-items:flex-end">
<button type="submit" class="form-submit" style="margin:0">Add Document</button>
</div>
</div>
</form>
</div>`;
return dashWrap(user, 'documents', body);
}
// ── VAC FEE CALCULATOR ────────────────────────────────────────────────────────
export function feesPage(user) {
const body = `
<div class="vt-section-title">💰 VAC Fee Calculator</div>
<p class="vt-section-sub">Estimate your Visa Application Charge (as at 202526)</p>
<div class="vt-card">
<h3>Select Visa & Applicants</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:20px">
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Visa Subclass</label>
<select id="vac-subclass" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem" onchange="calcVAC()">
${VAC_FEES.map(v => `<option value="${v.subclass}" data-p="${v.primary}" data-s="${v.secondary}" data-c="${v.child}" data-note="${esc(v.note||'')}">SC ${v.subclass}${v.name}</option>`).join('')}
</select>
</div>
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Secondary Applicants (partner)</label>
<input type="number" id="vac-secondary" value="0" min="0" max="5" onchange="calcVAC()" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Dependent Children</label>
<input type="number" id="vac-children" value="0" min="0" max="10" onchange="calcVAC()" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
</div>
<div id="vac-result" style="background:#f0f6ff;border-radius:10px;padding:20px">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;text-align:center;margin-bottom:16px">
<div><div style="font-size:.8rem;color:#64748b">Primary</div><div style="font-weight:700;color:#1a2744" id="vac-p-cost"></div></div>
<div><div style="font-size:.8rem;color:#64748b">Secondary</div><div style="font-weight:700;color:#1a2744" id="vac-s-cost"></div></div>
<div><div style="font-size:.8rem;color:#64748b">Children</div><div style="font-weight:700;color:#1a2744" id="vac-c-cost"></div></div>
<div style="border-left:2px solid #d1d9e8"><div style="font-size:.8rem;color:#64748b">Total VAC</div><div class="fee-total" id="vac-total"></div></div>
</div>
<div id="vac-note" style="font-size:.82rem;color:#64748b"></div>
<div style="font-size:.78rem;color:#94a3b8;margin-top:8px">Figures in AUD. VAC fees are subject to change; verify at homeaffairs.gov.au before lodging.</div>
</div>
</div>
<script>
function calcVAC(){
const sel=document.getElementById('vac-subclass');
const opt=sel.options[sel.selectedIndex];
const p=parseInt(opt.dataset.p)||0, s=parseInt(opt.dataset.s)||0, c=parseInt(opt.dataset.c)||0;
const ns=parseInt(document.getElementById('vac-secondary').value)||0;
const nc=parseInt(document.getElementById('vac-children').value)||0;
const tot=p+(s*ns)+(c*nc);
const fmt=n=>n===0?'$0':'$'+n.toLocaleString();
document.getElementById('vac-p-cost').textContent=fmt(p);
document.getElementById('vac-s-cost').textContent=ns>0?fmt(s*ns):'$0';
document.getElementById('vac-c-cost').textContent=nc>0?fmt(c*nc):'$0';
document.getElementById('vac-total').textContent='$'+tot.toLocaleString();
document.getElementById('vac-note').textContent=opt.dataset.note||'';
}
calcVAC();
</script>`;
return dashWrap(user, 'fees', body);
}
// ── OCCUPATION SEARCH ─────────────────────────────────────────────────────────
export function occupationsPage(user, query) {
const q = (query || '').toLowerCase().trim();
const results = q ? OCCUPATIONS.filter(o =>
o.title.toLowerCase().includes(q) ||
o.anzsco.includes(q) ||
o.authority.toLowerCase().includes(q)
) : OCCUPATIONS.slice(0, 30);
const body = `
<div class="vt-section-title">🔍 Occupation Search</div>
<p class="vt-section-sub">MLTSSL and STSOL occupations with visa eligibility and assessing authority</p>
<form method="GET" action="/dashboard/occupations" style="margin-bottom:24px">
<div style="display:flex;gap:10px">
<input type="text" name="q" value="${esc(query||'')}" placeholder="Search by job title, ANZSCO code, or authority…"
style="flex:1;padding:11px 14px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.95rem">
<button type="submit" class="btn btn-primary" style="padding:11px 24px;white-space:nowrap">Search</button>
</div>
</form>
${results.length === 0 ? `<div class="vt-card" style="text-align:center;color:#64748b;padding:48px">No occupations found for "${esc(q)}"</div>` : `
<div style="margin-bottom:10px;font-size:.9rem;color:#64748b">${q ? `${results.length} result${results.length!==1?'s':''} for "${esc(q)}"` : `Showing first ${results.length} occupations — search to filter`}</div>
${results.map(o => `
<div class="occ-row">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<div class="occ-title">${esc(o.title)}</div>
<div class="occ-meta">
<span>ANZSCO: ${esc(o.anzsco)}</span>
<span class="list-${o.list.toLowerCase()}">${esc(o.list)}</span>
<span>Authority: ${esc(o.authority)}</span>
</div>
</div>
<div style="display:flex;gap:5px;flex-wrap:wrap;flex-shrink:0">
${o.visas.map(v => `<span style="background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:10px;font-size:.75rem;font-weight:600">SC ${v}</span>`).join('')}
</div>
</div>
</div>`).join('')}`}
<div style="margin-top:20px;padding:14px 18px;background:#f0f6ff;border-radius:8px;font-size:.85rem;color:#1a2744">
💡 <strong>MLTSSL</strong> occupations can be nominated for SC 189 (independent), 190, 491, 482, and 186.
<strong>STSOL</strong> occupations require state/employer sponsorship (190, 491, 482). Always verify the current list at
<a href="https://immi.homeaffairs.gov.au" target="_blank">immi.homeaffairs.gov.au</a>.
</div>`;
return dashWrap(user, 'occupations', body);
}
// ── STATE NOMINATION CRITERIA ─────────────────────────────────────────────────
export function stateCriteriaPage(user) {
const body = `
<div class="vt-section-title">🗺 State Nomination Criteria</div>
<p class="vt-section-sub">Minimum points and key requirements for SC 190 and SC 491 by state/territory (202526)</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
${STATE_CRITERIA.map(s => `
<div class="state-card">
<h4>${s.fullName} (${s.state})</h4>
<div style="margin-bottom:10px">
<div style="font-size:.8rem;font-weight:700;color:#1a5bb8;margin-bottom:4px">SC 190 Skilled Nominated</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="background:#dbeafe;color:#1d4ed8;padding:3px 10px;border-radius:12px;font-size:.85rem;font-weight:700">${s.sc190.minPoints ? s.sc190.minPoints + ' pts min' : 'N/A'}</span>
</div>
<div style="font-size:.82rem;color:#64748b">${esc(s.sc190.notes)}</div>
</div>
<div style="margin-bottom:12px">
<div style="font-size:.8rem;font-weight:700;color:#16a34a;margin-bottom:4px">SC 491 Skilled Regional</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="background:#dcfce7;color:#166534;padding:3px 10px;border-radius:12px;font-size:.85rem;font-weight:700">${s.sc491.minPoints != null ? s.sc491.minPoints + ' pts min' : 'N/A'}</span>
</div>
<div style="font-size:.82rem;color:#64748b">${esc(s.sc491.notes)}</div>
</div>
<a href="${esc(s.website)}" target="_blank" style="font-size:.82rem;color:#1a5bb8;font-weight:600">Official site </a>
</div>`).join('')}
</div>
<div style="margin-top:20px;padding:14px 18px;background:#fffbeb;border-radius:8px;font-size:.85rem;color:#92400e;border:1px solid #fde68a">
State criteria change frequently and may close mid-year. Always verify directly with the state migration authority before applying.
</div>
<style>@media(max-width:700px){.state-card+.state-card{margin-top:0}div[style*="grid-template-columns:1fr 1fr"]{grid-template-columns:1fr!important}}</style>`;
return dashWrap(user, 'state-criteria', body);
}
// ── PROCESSING TIMES ──────────────────────────────────────────────────────────
export function processingTimesPage(user) {
const body = `
<div class="vt-section-title"> Visa Processing Times</div>
<p class="vt-section-sub">Estimated processing times in months (percentile-based, 202526)</p>
<div class="vt-card">
<div class="proc-row proc-header" style="border-bottom:2px solid #e8f0fe;margin-bottom:4px">
<span>Visa</span><span style="text-align:center">P25</span><span style="text-align:center">P50</span><span style="text-align:center">P75</span><span style="text-align:center">P90</span>
</div>
${PROCESSING_TIMES.map(v => `
<div class="proc-row">
<div>
<div style="font-weight:600;font-size:.9rem;color:#1a2744">SC ${v.subclass} ${v.name}</div>
${v.note ? `<div style="font-size:.78rem;color:#94a3b8">${esc(v.note)}</div>` : ''}
</div>
<span class="p-val">${v.p25}m</span>
<span class="p-val">${v.p50}m</span>
<span class="p-val">${v.p75}m</span>
<span class="p-val">${v.p90}m</span>
</div>`).join('')}
</div>
<div style="padding:14px 18px;background:#f0f6ff;border-radius:8px;font-size:.85rem;color:#1a2744">
<strong>Reading this table:</strong> P50 means 50% of applications are decided within that many months.
P25 = fastest quarter, P90 = slowest 10%. Times are estimates check
<a href="https://immi.homeaffairs.gov.au/visas/getting-a-visa/visa-processing-times" target="_blank">DHA's live dashboard</a> for current figures.
</div>`;
return dashWrap(user, 'processing-times', body);
}
// ── ENGLISH REQUIREMENTS ──────────────────────────────────────────────────────
export function englishPage(user) {
const body = `
<div class="vt-section-title">🗣 English Requirements</div>
<p class="vt-section-sub">Required scores by visa subclass across IELTS, PTE, TOEFL, and OET</p>
<div class="vt-card" style="padding:0;overflow:hidden">
<table class="eng-table">
<thead>
<tr>
<th>Visa</th>
<th>Level Required</th>
<th>IELTS</th>
<th>PTE Academic</th>
<th>TOEFL iBT</th>
<th>OET</th>
</tr>
</thead>
<tbody>
${ENGLISH_REQUIREMENTS.map(e => `
<tr>
<td><strong>SC ${e.visa}</strong><br><span style="font-size:.8rem;color:#64748b">${esc(e.name)}</span></td>
<td><span style="background:#dbeafe;color:#1d4ed8;padding:2px 8px;border-radius:10px;font-size:.8rem;font-weight:600;white-space:nowrap">${esc(e.level)}</span></td>
<td style="font-family:monospace;font-size:.85rem">${esc(e.ielts)}</td>
<td style="font-family:monospace;font-size:.85rem">${esc(e.pte)}</td>
<td style="font-family:monospace;font-size:.85rem">${esc(e.toefl)}</td>
<td style="font-family:monospace;font-size:.85rem">${esc(e.oet)}</td>
</tr>
<tr><td colspan="6" style="font-size:.8rem;color:#64748b;padding:4px 12px 12px;background:#fafbff">${esc(e.notes)}</td></tr>
`).join('')}
</tbody>
</table>
</div>
<div style="padding:14px 18px;background:#fffbeb;border-radius:8px;font-size:.85rem;color:#92400e;border:1px solid #fde68a">
Scores shown are component-by-component minimums. Some subclasses require all four skills to meet the threshold individually. Verify on the DHA website before testing.
</div>
<style>.eng-table{overflow-x:auto}</style>`;
return dashWrap(user, 'english', body);
}
// ── STUDENT FUND CALCULATOR ────────────────────────────────────────────────────
export function studentFundPage(user) {
const body = `
<div class="vt-section-title">🎓 Student Fund Calculator</div>
<p class="vt-section-sub">Minimum financial capacity required for an Australian student visa (SC 500)</p>
<div class="vt-card">
<h3>Calculate Required Funds</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:20px">
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Tuition Fees (per year, AUD)</label>
<input type="number" id="sf-tuition" value="25000" min="0" step="500" oninput="calcSF()"
style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Course Duration (years)</label>
<input type="number" id="sf-years" value="2" min="0.5" max="8" step="0.5" oninput="calcSF()"
style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Accompanying Partner?</label>
<select id="sf-partner" onchange="calcSF()" style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
<option value="0">No partner</option>
<option value="1">Yes 1 partner</option>
</select>
</div>
<div>
<label style="display:block;font-weight:600;font-size:.85rem;margin-bottom:5px">Number of Dependent Children</label>
<input type="number" id="sf-children" value="0" min="0" max="8" oninput="calcSF()"
style="width:100%;padding:10px 12px;border:1.5px solid #d1d9e8;border-radius:8px;font-size:.9rem">
</div>
</div>
<div class="fund-result" id="sf-result">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px;text-align:center">
<div><div style="font-size:.8rem;color:#64748b">Tuition (total)</div><div style="font-weight:700" id="sf-r-tuition"></div></div>
<div><div style="font-size:.8rem;color:#64748b">Living Costs</div><div style="font-weight:700" id="sf-r-living"></div></div>
<div><div style="font-size:.8rem;color:#64748b">Travel (est.)</div><div style="font-weight:700" id="sf-r-travel"></div></div>
<div><div style="font-size:.8rem;color:#64748b">Partner</div><div style="font-weight:700" id="sf-r-partner"></div></div>
<div><div style="font-size:.8rem;color:#64748b">Children</div><div style="font-weight:700" id="sf-r-children"></div></div>
</div>
<div style="border-top:2px solid #bbf7d0;padding-top:16px;text-align:center">
<div style="font-size:.85rem;color:#166534;margin-bottom:4px">Minimum Funds Required</div>
<div class="fund-total" id="sf-total"></div>
</div>
</div>
<div style="margin-top:12px;font-size:.8rem;color:#94a3b8">${esc(STUDENT_FUND.note)}</div>
</div>
<div class="vt-card">
<h3>202526 Living Cost Figures (DHA)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:.9rem">
<div>Principal applicant: <strong>$${STUDENT_FUND.livingCostPerYear.toLocaleString()}/year</strong></div>
<div>Partner: <strong>$${STUDENT_FUND.partnerPerYear.toLocaleString()}/year</strong></div>
<div>Each child: <strong>$${STUDENT_FUND.childPerYear.toLocaleString()}/year</strong></div>
<div>Travel (estimate): <strong>~$4,000 return</strong></div>
</div>
</div>
<script>
function calcSF(){
const tuition=parseFloat(document.getElementById('sf-tuition').value)||0;
const years=parseFloat(document.getElementById('sf-years').value)||1;
const partner=parseInt(document.getElementById('sf-partner').value)||0;
const children=parseInt(document.getElementById('sf-children').value)||0;
const living=${STUDENT_FUND.livingCostPerYear}*years;
const travelEst=4000;
const partnerCost=${STUDENT_FUND.partnerPerYear}*years*partner;
const childCost=${STUDENT_FUND.childPerYear}*years*children;
const tuitionTotal=tuition*years;
const total=tuitionTotal+living+travelEst+partnerCost+childCost;
const fmt=n=>'$'+Math.round(n).toLocaleString();
document.getElementById('sf-r-tuition').textContent=fmt(tuitionTotal);
document.getElementById('sf-r-living').textContent=fmt(living);
document.getElementById('sf-r-travel').textContent=fmt(travelEst);
document.getElementById('sf-r-partner').textContent=fmt(partnerCost);
document.getElementById('sf-r-children').textContent=fmt(childCost);
document.getElementById('sf-total').textContent=fmt(total);
}
calcSF();
</script>`;
return dashWrap(user, 'student-fund', body);
}