2026-04-03 20:43:00 -04:00

1427 lines
48 KiB
JavaScript

/**
* Tabs use full page navigation (?tab=) from index.php; active state is set server-side.
* Do not programmatically .click() <a class="tab"> links on load — that re-follows href and reloads in a tight loop.
*/
(function ($) {
'use strict';
var apiBase = typeof window.familyHubApiBase === 'string' ? window.familyHubApiBase : '/api';
/**
* Avoids JSON.parse on empty bodies (Firefox: "unexpected end of data at line 1 column 1")
* and surfaces clearer errors when PHP returns HTML or nothing.
*/
function parseJsonResponseText(res, text) {
var trimmed = (text || '').trim();
if (trimmed === '') {
var emptyErr = new Error(
!res.ok ? (res.statusText || 'Request failed') : 'Empty response from server.'
);
emptyErr.payload = {};
emptyErr.status = res.status;
throw emptyErr;
}
try {
return JSON.parse(trimmed);
} catch (e) {
var parseErr = new Error('Server did not return JSON. Check server logs or network tab.');
parseErr.payload = trimmed.slice(0, 500);
parseErr.status = res.status;
throw parseErr;
}
}
function postJson(path, body) {
return fetch(apiBase + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body || {})
}).then(function (res) {
return res.text().then(function (text) {
var data = parseJsonResponseText(res, text);
if (!res.ok) {
var err = new Error(data.error || res.statusText || 'Request failed');
err.payload = data;
err.status = res.status;
throw err;
}
return data;
});
});
}
function showAlert($el, kind, message) {
if (!$el || !$el.length) {
return;
}
$el.removeClass('d-none alert-success alert-danger alert-warning');
$el.addClass('alert-' + kind);
$el.text(message);
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
var pendingPersonId = null;
var pinModal = null;
function switchPerson(personId, pin) {
return postJson('/switch_person.php', { personId: personId, pin: pin || '' });
}
function bindPersonaSwitcher() {
$(document).on('click', '.persona-chip', function () {
var $btn = $(this);
var id = $btn.data('person-id');
var needsPin = String($btn.data('needs-pin')) === '1';
var name = $btn.data('person-name') || 'this profile';
if (!id) {
return;
}
if (needsPin) {
pendingPersonId = id;
$('#hohPinModalPrompt').text('Enter PIN for ' + name + '.');
$('#hohPinInput').val('');
$('#hohPinError').addClass('d-none').text('');
if (!pinModal && typeof bootstrap !== 'undefined') {
pinModal = new bootstrap.Modal(document.getElementById('hohPinModal'));
}
if (pinModal) {
pinModal.show();
}
setTimeout(function () {
document.getElementById('hohPinInput').focus();
}, 200);
return;
}
switchPerson(id, '')
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not switch profile');
});
});
$('#hohPinSubmit').on('click', function () {
var pin = $('#hohPinInput').val() || '';
if (!pendingPersonId) {
return;
}
$('#hohPinError').addClass('d-none').text('');
switchPerson(pendingPersonId, pin)
.then(function () {
if (pinModal) {
pinModal.hide();
}
pendingPersonId = null;
window.location.reload();
})
.catch(function (err) {
$('#hohPinError').removeClass('d-none').text(err.message || 'PIN incorrect');
});
});
}
function bindFirstHeadForm() {
var $form = $('#firstHeadForm');
if (!$form.length) {
return;
}
$form.on('submit', function (e) {
e.preventDefault();
var payload = {
name: $('#first_name').val(),
pin: $('#first_pin').val(),
favoriteColor: $('#first_favoriteColor').val()
};
postJson('/people_create_first.php', payload)
.then(function () {
showAlert($('#firstHeadFeedback'), 'success', 'Profile created. Reloading…');
window.location.reload();
})
.catch(function (err) {
showAlert($('#firstHeadFeedback'), 'danger', err.message || 'Could not create profile');
$('#firstHeadFeedback').removeClass('d-none');
});
});
}
function toggleAddPinVisibility() {
var role = $('#add_role').val();
var $wrap = $('#add_pin_wrap');
var $pin = $('#add_pin');
if (role === 'head_of_household') {
$wrap.show();
$pin.prop('required', true);
} else {
$wrap.hide();
$pin.prop('required', false);
$pin.val('');
}
}
function bindAddPersonForm() {
var $form = $('#addPersonForm');
if (!$form.length) {
return;
}
$('#add_role').on('change', toggleAddPinVisibility);
toggleAddPinVisibility();
$form.on('submit', function (e) {
e.preventDefault();
var role = $('#add_role').val();
var payload = {
name: $('#add_name').val(),
role: role,
pin: role === 'head_of_household' ? $('#add_pin').val() : '',
favoriteColor: $('#add_favoriteColor').val()
};
postJson('/people_create.php', payload)
.then(function () {
showAlert($('#addPersonFeedback'), 'success', 'Person added. Reloading…');
$('#addPersonFeedback').removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
showAlert($('#addPersonFeedback'), 'danger', err.message || 'Could not add person');
$('#addPersonFeedback').removeClass('d-none');
});
});
}
function toggleEditPersonPinVisibility() {
var initial = $('#edit_person_initial_role').val() || '';
var role = $('#edit_role').val() || '';
var $wrap = $('#edit_pin_wrap');
var $pin = $('#edit_pin');
var needPin = role === 'head_of_household' && initial !== 'head_of_household';
if (needPin) {
$wrap.show();
$pin.prop('required', true);
} else {
$wrap.hide();
$pin.prop('required', false);
$pin.val('');
}
}
function bindEditPersonForm() {
var $form = $('#editPersonForm');
if (!$form.length) {
return;
}
var peopleById = {};
var $payloadEl = $('#settingsPeopleEditPayload');
if ($payloadEl.length) {
try {
var plist = JSON.parse($payloadEl.text());
if (Array.isArray(plist)) {
plist.forEach(function (row) {
if (row && row.id) {
peopleById[row.id] = row;
}
});
}
} catch (ignoreErr) {
peopleById = {};
}
}
var editModal = null;
if (typeof bootstrap !== 'undefined') {
var modalEl = document.getElementById('editPersonModal');
if (modalEl) {
editModal = new bootstrap.Modal(modalEl, { backdrop: false });
}
}
$('#edit_role').on('change', toggleEditPersonPinVisibility);
$(document).on('click', '.btn-edit-person', function () {
var id = $(this).data('person-id');
if (!id || !peopleById[id]) {
window.alert('Could not load this person for editing.');
return;
}
var p = peopleById[id];
$('#edit_person_id').val(p.id);
$('#edit_person_initial_role').val(p.role || '');
$('#edit_name').val(p.name || '');
$('#edit_role').val(p.role || 'child');
var fc = String(p.favoriteColor || '').trim();
if (!/^#[0-9A-Fa-f]{6}$/.test(fc)) {
fc = '#4a90e2';
}
$('#edit_favoriteColor').val(fc);
$('#edit_birthday').val(String(p.birthday || '').trim());
$('#edit_description').val(p.description || '');
$('#edit_pin').val('');
$('#editPersonFeedback').addClass('d-none').removeClass('alert-success alert-danger alert-warning').text('');
toggleEditPersonPinVisibility();
if (editModal) {
editModal.show();
}
});
$form.on('submit', function (e) {
e.preventDefault();
var id = ($('#edit_person_id').val() || '').trim();
var initial = $('#edit_person_initial_role').val() || '';
var role = $('#edit_role').val() || '';
var name = ($('#edit_name').val() || '').trim();
if (!id || name === '') {
return;
}
var needPin = role === 'head_of_household' && initial !== 'head_of_household';
var pin = needPin ? ($('#edit_pin').val() || '') : '';
if (needPin && pin.length < 4) {
showAlert($('#editPersonFeedback'), 'danger', 'PIN must be at least 4 characters for a new Head of household.');
$('#editPersonFeedback').removeClass('d-none');
return;
}
if (initial === 'head_of_household' && role !== 'head_of_household') {
if (!window.confirm('Demote this Head of household? Their PIN will be cleared until they are promoted again.')) {
return;
}
}
var payload = {
id: id,
name: name,
role: role,
favoriteColor: $('#edit_favoriteColor').val() || '#4a90e2',
birthday: ($('#edit_birthday').val() || '').trim(),
description: ($('#edit_description').val() || '').trim()
};
if (needPin) {
payload.pin = pin;
}
postJson('/people_update.php', payload)
.then(function () {
window.location.reload();
})
.catch(function (err) {
showAlert($('#editPersonFeedback'), 'danger', err.message || 'Could not save changes');
$('#editPersonFeedback').removeClass('d-none');
});
});
}
function bindFamilySettingsForm() {
var $form = $('#familySettingsForm');
if (!$form.length) {
return;
}
$form.on('submit', function (e) {
e.preventDefault();
var payload = {
currency_symbol: $('#currency_symbol').val(),
currency_name: $('#currency_name').val(),
currency_permanence: $('#currency_permanence').val(),
banking_enabled: $('#banking_enabled').is(':checked'),
banking_auto_split_enabled: $('#banking_auto_split_enabled').is(':checked'),
banking_auto_split_savings_pct: Number($('#banking_auto_split_savings_pct').val()) || 0,
banking_auto_split_charity_pct: Number($('#banking_auto_split_charity_pct').val()) || 0,
banking_savings_monthly_interest_rate: Number($('#banking_savings_monthly_interest_rate').val()) || 0,
banking_roundup_destination: $('#banking_roundup_destination').val() || 'off',
week_starts_on: parseInt($('#week_starts_on').val(), 10),
nfc_base_url: ($('#nfc_base_url').val() || '').trim(),
nfc_show_confirmation: $('#nfc_show_confirmation').is(':checked'),
nfc_scan_cooldown_seconds: parseInt($('#nfc_scan_cooldown_seconds').val(), 10) || 0
};
postJson('/family_settings_save.php', payload)
.then(function () {
showAlert($('#familySettingsFeedback'), 'success', 'Saved.');
$('#familySettingsFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not save');
$('#familySettingsFeedback').removeClass('d-none');
});
});
$('#btnDisableAllChoreNfc').on('click', function () {
if (!window.confirm('Disable NFC links for all chores?')) {
return;
}
postJson('/family_nfc_admin.php', { action: 'disable_all_chore_nfc' })
.then(function () {
showAlert($('#familySettingsFeedback'), 'warning', 'All chore NFC links disabled.');
$('#familySettingsFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not disable NFC links');
$('#familySettingsFeedback').removeClass('d-none');
});
});
$('#btnRotateAllPersonNfcTokens').on('click', function () {
if (!window.confirm('Rotate NFC submitter tokens for all people? Existing person-token links will stop working.')) {
return;
}
postJson('/family_nfc_admin.php', { action: 'rotate_all_person_tokens' })
.then(function () {
showAlert($('#familySettingsFeedback'), 'success', 'All person NFC tokens rotated.');
$('#familySettingsFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not rotate tokens');
$('#familySettingsFeedback').removeClass('d-none');
});
});
}
function collectBillDaysFromDom() {
var out = [];
$('#billDaysRows .bill-day-row').each(function () {
var $row = $(this);
var day = parseInt($row.find('.bill-day-dom').val(), 10);
var title = ($row.find('.bill-day-title').val() || '').trim();
if (title !== '' && day >= 1 && day <= 31) {
out.push({ dayOfMonth: day, title: title });
}
});
return out;
}
function bindCalendarSettingsForm() {
var $form = $('#calendarSettingsForm');
if (!$form.length) {
return;
}
$('#btnAddBillDayRow').on('click', function () {
var $first = $('#billDaysRows .bill-day-row').first();
if (!$first.length) {
return;
}
var $clone = $first.clone();
$clone.find('.bill-day-dom').val('1');
$clone.find('.bill-day-title').val('');
$('#billDaysRows').append($clone);
});
$form.on('submit', function (e) {
e.preventDefault();
var payload = {
timezone: $('#family_timezone').val(),
calendar_two_way_google: $('#calendar_two_way_google').is(':checked'),
calendar_bill_days: collectBillDaysFromDom()
};
postJson('/family_settings_save.php', payload)
.then(function () {
showAlert($('#calendarSettingsFeedback'), 'success', 'Saved.');
$('#calendarSettingsFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#calendarSettingsFeedback'), 'danger', err.message || 'Could not save');
$('#calendarSettingsFeedback').removeClass('d-none');
});
});
}
function choreListPayloadFromForm() {
var type = $('#chore_list_type').val() || 'checkbox';
var raw = $('#chore_list_items').val() || '';
var lines = raw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
if (lines.length === 0) {
return [];
}
return [{ type: type, items: lines }];
}
function toggleChoreRecurrence() {
var sched = $('#chore_schedule').val();
if (sched === 'recurring') {
$('#chore_recurrence_wrap').show();
} else {
$('#chore_recurrence_wrap').hide();
}
}
function bindChoresPage() {
var $choresTab = $('#chores');
var $form = $('#choreForm');
// Add/edit form exists only for HoH (or when editing). Cards and pending list still need handlers for everyone.
if ($choresTab.length) {
$(document).on('click', '.btn-chore-submit', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Submit this chore as done? A Head of household will approve it before the reward is paid.')) {
return;
}
postJson('/chore_submit.php', { id: id, note: '' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not submit');
});
});
$(document).on('click', '.btn-chore-approve', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Approve and pay the reward?')) {
return;
}
postJson('/chore_review.php', { id: id, decision: 'approve' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not approve');
});
});
$(document).on('click', '.btn-chore-reject', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Reject this submission?')) {
return;
}
postJson('/chore_review.php', { id: id, decision: 'reject' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not reject');
});
});
$(document).on('click', '.btn-chore-nfc-generate', function () {
var id = $(this).data('id');
if (!id) {
return;
}
postJson('/chore_nfc_rotate.php', { id: id })
.then(function (data) {
var links = Array.isArray(data.links) ? data.links : [];
var html = '<div class="small text-muted mb-1"><strong>NFC links (copy per person)</strong></div>';
if (!links.length) {
html += '<div class="small text-muted">No links were returned.</div>';
} else {
links.forEach(function (row) {
html += '<label class="form-label small mb-1">' + escapeHtml(row.person_name || row.person_id || 'Person') + '</label>';
html += '<div class="input-group input-group-sm mb-2">';
html += '<input type="text" class="form-control chore-nfc-link-input" readonly value="' + escapeHtml(row.url || '') + '">';
html += '<button class="btn btn-outline-secondary btn-copy-nfc-link" type="button">Copy</button>';
html += '</div>';
});
}
var $links = $('#chore_nfc_links_' + id);
$links.html(html).removeClass('d-none');
showAlert($('#chore_nfc_feedback_' + id), 'success', 'Generated new NFC links.');
$('#chore_nfc_feedback_' + id).removeClass('d-none');
})
.catch(function (err) {
showAlert($('#chore_nfc_feedback_' + id), 'danger', err.message || 'Could not generate links');
$('#chore_nfc_feedback_' + id).removeClass('d-none');
});
});
$(document).on('click', '.btn-copy-nfc-link', function () {
var $input = $(this).closest('.input-group').find('.chore-nfc-link-input');
var val = $input.val() || '';
if (!val) {
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(String(val));
} else {
$input.trigger('focus').trigger('select');
document.execCommand('copy');
}
});
$(document).on('click', '.btn-chore-nfc-toggle', function () {
var id = $(this).data('id');
var enable = String($(this).data('enable')) === '1';
if (!id) {
return;
}
postJson('/chore_nfc_toggle.php', { id: id, enabled: enable })
.then(function () {
window.location.reload();
})
.catch(function (err) {
showAlert($('#chore_nfc_feedback_' + id), 'danger', err.message || 'Could not update NFC status');
$('#chore_nfc_feedback_' + id).removeClass('d-none');
});
});
}
if (!$form.length) {
return;
}
$('#chore_schedule').on('change', toggleChoreRecurrence);
toggleChoreRecurrence();
$form.on('submit', function (e) {
e.preventDefault();
var assignees = [];
$('.chore-assignee:checked').each(function () {
assignees.push($(this).val());
});
var due = $('#chore_due_date').val() || '';
var payload = {
id: ($('#chore_id').val() || '').trim(),
title: $('#chore_title').val(),
description: $('#chore_description').val(),
image: $('#chore_image').val(),
value: Number($('#chore_value').val()) || 0,
due_date: due,
schedule: $('#chore_schedule').val(),
recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7,
assignee_ids: assignees,
anyone_can_complete: $('#chore_anyone_can_complete').is(':checked'),
lists: choreListPayloadFromForm()
};
postJson('/chore_save.php', payload)
.then(function () {
window.location.href = window.location.pathname + '?tab=chores';
})
.catch(function (err) {
var $fb = $('#choreFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not save chore');
$fb.removeClass('d-none');
});
});
$('#choreDeleteBtn').on('click', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Delete this chore?')) {
return;
}
postJson('/chore_delete.php', { id: id })
.then(function () {
window.location.href = window.location.pathname + '?tab=chores';
})
.catch(function (err) {
window.alert(err.message || 'Could not delete');
});
});
}
function groceryReloadKeepParams() {
var $tab = $('#groceries');
var store = $tab.data('store-id') || $('#grocery_store_id').val();
var params = new URLSearchParams(window.location.search);
var filter = params.get('filter') || 'active';
var q = '?tab=groceries&store=' + encodeURIComponent(store || '') + '&filter=' + encodeURIComponent(filter);
window.location.href = window.location.pathname + q;
}
function bindStoreManagement() {
$('#storeAddForm').on('submit', function (e) {
e.preventDefault();
var name = ($('#new_store_name').val() || '').trim();
if (!name) {
return;
}
postJson('/store_create.php', { name: name })
.then(function () {
window.location.reload();
})
.catch(function (err) {
var $fb = $('#storeAddFeedback');
showAlert($fb, 'danger', err.message || 'Could not add store');
$fb.removeClass('d-none');
});
});
$(document).on('click', '.btn-store-delete', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Delete this store? Lists must be empty.')) {
return;
}
postJson('/store_delete.php', { id: id })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not delete');
});
});
$(document).on('click', '.btn-store-rename', function () {
var id = $(this).data('id');
var current = $(this).data('name') || '';
var name = window.prompt('Store name', current);
if (name === null) {
return;
}
name = String(name).trim();
if (!name) {
return;
}
postJson('/store_update.php', { id: id, name: name })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not rename');
});
});
}
function bindGroceryPage() {
var $tab = $('#groceries');
if (!$tab.length) {
return;
}
$('#grocery_catalog_pick').on('change', function () {
var v = $(this).val();
if (!v) {
return;
}
var $o = $(this).find('option:selected');
$('#grocery_name').val($o.data('name') || '');
$('#grocery_description').val($o.data('description') || '');
$('#grocery_size').val($o.data('size') || '');
$('#grocery_image').val($o.data('image') || '');
});
$('#groceryAddForm').on('submit', function (e) {
e.preventDefault();
var storeId = ($('#grocery_store_id').val() || $tab.data('store-id') || '').trim();
var payload = {
storeId: storeId,
catalogPickId: $('#grocery_catalog_pick').val() || '',
name: $('#grocery_name').val(),
description: $('#grocery_description').val(),
size: $('#grocery_size').val(),
quantity: $('#grocery_quantity').val(),
price: $('#grocery_price').val(),
image: $('#grocery_image').val(),
recurringIntervalDays: parseInt($('#grocery_recurring').val(), 10) || 0,
source: 'manual'
};
postJson('/grocery_item_create.php', payload)
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
var $fb = $('#groceryFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not add item');
$fb.removeClass('d-none');
});
});
$(document).on('click', '.btn-grocery-purchase', function () {
var storeId = $tab.data('store-id');
var itemId = $(this).data('item-id');
var purchased = String($(this).data('purchased')) === '1';
postJson('/grocery_item_purchase.php', {
storeId: storeId,
itemId: itemId,
purchased: purchased
})
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
window.alert(err.message || 'Could not update');
});
});
$(document).on('click', '.btn-grocery-delete', function () {
var storeId = $tab.data('store-id');
var itemId = $(this).data('item-id');
if (!itemId || !window.confirm('Remove this item?')) {
return;
}
postJson('/grocery_item_delete.php', { storeId: storeId, itemId: itemId })
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
window.alert(err.message || 'Could not remove');
});
});
$(document).on('click', '.btn-grocery-approve', function () {
var storeId = $tab.data('store-id');
var itemId = $(this).data('item-id');
if (!itemId) {
return;
}
postJson('/grocery_item_review.php', { storeId: storeId, itemId: itemId })
.then(function () {
groceryReloadKeepParams();
})
.catch(function (err) {
window.alert(err.message || 'Could not approve');
});
});
}
function mealListPayloadFromForm() {
var type = $('#meal_list_type').val() || 'checkbox';
var raw = $('#meal_list_items').val() || '';
var lines = raw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
if (lines.length === 0) {
return [];
}
return [{ type: type, items: lines }];
}
function mealGroceryRowsPayload() {
var out = [];
$('#mealGroceryRows .meal-grocery-row').each(function () {
var $r = $(this);
var name = ($r.find('.g-name').val() || '').trim();
if (!name) {
return;
}
out.push({
name: name,
storeId: ($r.find('.g-store').val() || '').trim(),
quantity: ($r.find('.g-qty').val() || '1').trim() || '1',
size: ($r.find('.g-size').val() || '').trim(),
description: '',
price: '',
image: ''
});
});
return out;
}
function readMealsLibrary() {
var el = document.getElementById('mealsLibraryJson');
if (!el) {
return [];
}
var raw = (el.textContent || '').trim();
if (raw === '') {
return [];
}
try {
return JSON.parse(raw);
} catch (e) {
return [];
}
}
function hideMealSlotModal() {
var el = document.getElementById('mealSlotModal');
if (el && typeof bootstrap !== 'undefined') {
var inst = bootstrap.Modal.getInstance(el);
if (inst) {
inst.hide();
}
}
}
function bindMealsPage() {
var $tab = $('#meals');
if (!$tab.length) {
return;
}
var weekStart = ($tab.attr('data-week-start') || '').trim();
var mealImportParsed = null;
$(document).on('click', '.btn-meal-shopping-to-grocery', function () {
var $btn = $(this);
var mealId = $btn.data('meal-id');
var itemIndex = parseInt($btn.attr('data-item-index'), 10);
var label = ($btn.attr('data-item-name') || $btn.data('itemName') || '').trim() || 'Item';
var $li = $btn.closest('li');
var storeId = $li.find('.meal-shopping-store').val() || '';
var $fb = $('#mealShoppingGroceryFeedback');
if (!mealId || itemIndex < 0 || isNaN(itemIndex)) {
return;
}
postJson('/meal_shopping_item_to_grocery.php', { mealId: mealId, itemIndex: itemIndex, storeId: storeId })
.then(function () {
if ($fb.length) {
showAlert($fb, 'success', 'Added “' + label + '” to the grocery list (pending review).');
$fb.removeClass('d-none');
var el = $fb.get(0);
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
})
.catch(function (err) {
if ($fb.length) {
showAlert($fb, 'danger', (err.message || 'Could not add') + ' — “' + label + '”');
$fb.removeClass('d-none');
}
});
});
$(document).on('click', '.btn-ingredient-to-grocery', function () {
var $btn = $(this);
var mealId = $btn.data('meal-id');
var ing = $btn.attr('data-ingredient') || $btn.data('ingredient') || '';
var label = String(ing).trim() || 'Item';
var $li = $btn.closest('li');
var storeId = $li.find('.ingredient-store').val() || '';
var $fb = $('#mealPantryGroceryFeedback');
postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId })
.then(function () {
if ($fb.length) {
showAlert($fb, 'success', 'Added “' + label + '” to the grocery list (pending review).');
$fb.removeClass('d-none');
var el = $fb.get(0);
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
})
.catch(function (err) {
if ($fb.length) {
showAlert($fb, 'danger', (err.message || 'Could not add') + ' — “' + label + '”');
$fb.removeClass('d-none');
}
});
});
$('#btnMealWeekApply').on('click', function () {
var ws = ($('#meal_week_start').val() || '').trim();
if (!ws) {
return;
}
if (!window.confirm('Change the planning week? This clears the grid for the new week.')) {
return;
}
postJson('/meal_plan_set_week.php', { weekStart: ws })
.then(function () {
window.location.reload();
})
.catch(function (err) {
showAlert($('#mealWeekFeedback'), 'danger', err.message || 'Could not set week');
$('#mealWeekFeedback').removeClass('d-none');
});
});
var slotModalEl = document.getElementById('mealSlotModal');
var slotModal = null;
if (slotModalEl && typeof bootstrap !== 'undefined') {
slotModal = new bootstrap.Modal(slotModalEl, { backdrop: false });
}
function renderSlotPickerList() {
var lib = readMealsLibrary();
var filter = ($('#slotPickerFilter').val() || '').toLowerCase();
var $ul = $('#slotPickerList');
$ul.empty();
lib.forEach(function (m) {
if (filter) {
var tags = (m.tags || []).map(function (t) { return String(t).toLowerCase(); });
if (tags.indexOf(filter) === -1) {
return;
}
}
var $li = $('<li class="list-group-item list-group-item-action" role="button"></li>')
.text(m.title || m.id)
.attr('data-meal-id', m.id);
$ul.append($li);
});
if ($ul.children().length === 0) {
$ul.append('<li class="list-group-item text-muted">No meals match this filter.</li>');
}
}
$(document).on('click', '.btn-open-slot-picker', function () {
var day = $(this).data('day');
var mealType = $(this).data('meal-type');
$('#slotPickerDay').val(String(day));
$('#slotPickerType').val(String(mealType));
if (mealType) {
$('#slotPickerFilter').val(String(mealType));
}
renderSlotPickerList();
if (slotModal) {
slotModal.show();
}
});
$('#slotPickerFilter').on('change', renderSlotPickerList);
$(document).on('click', '#slotPickerList li[data-meal-id]', function () {
var mealId = $(this).attr('data-meal-id');
if (!mealId || !weekStart) {
return;
}
var day = parseInt($('#slotPickerDay').val(), 10);
var mealType = $('#slotPickerType').val();
var pushGrocery = $('#slotPickerPushGrocery').is(':checked');
postJson('/meal_plan_set_slot.php', {
weekStart: weekStart,
day: day,
mealType: mealType,
mealId: String(mealId),
pushGrocery: pushGrocery
})
.then(function () {
hideMealSlotModal();
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not assign meal');
});
});
$('#slotPickerClear').on('click', function () {
if (!weekStart) {
return;
}
var day = parseInt($('#slotPickerDay').val(), 10);
var mealType = $('#slotPickerType').val();
postJson('/meal_plan_set_slot.php', {
weekStart: weekStart,
day: day,
mealType: mealType,
mealId: '',
pushGrocery: false
})
.then(function () {
hideMealSlotModal();
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not clear slot');
});
});
$('#mealSaveForm').on('submit', function (e) {
e.preventDefault();
var tags = [];
$('.meal-tag-cb:checked').each(function () {
tags.push($(this).val());
});
var ingRaw = $('#meal_ingredients').val() || '';
var ingredients = ingRaw.split(/\r?\n/).map(function (l) { return l.trim(); }).filter(Boolean);
var payload = {
id: ($('#meal_id').val() || '').trim(),
title: ($('#meal_title').val() || '').trim(),
image: $('#meal_image').val(),
description: $('#meal_description').val(),
directions: $('#meal_directions').val(),
lists: mealListPayloadFromForm(),
tags: tags,
ingredients: ingredients,
items: mealGroceryRowsPayload()
};
postJson('/meal_save.php', payload)
.then(function () {
window.location.href = window.location.pathname + '?tab=meals';
})
.catch(function (err) {
showAlert($('#mealSaveFeedback'), 'danger', err.message || 'Could not save');
$('#mealSaveFeedback').removeClass('d-none');
});
});
$('#btnAddGroceryRow').on('click', function () {
var $first = $('#mealGroceryRows .meal-grocery-row').first();
if (!$first.length) {
return;
}
var $clone = $first.clone();
$clone.find('input').val('');
$clone.find('.g-qty').val('1');
$clone.find('select').prop('selectedIndex', 0);
$('#mealGroceryRows').append($clone);
});
$(document).on('click', '.btn-meal-delete', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Delete this meal from the library?')) {
return;
}
postJson('/meal_delete.php', { id: id })
.then(function () {
window.location.href = window.location.pathname + '?tab=meals';
})
.catch(function (err) {
window.alert(err.message || 'Could not delete');
});
});
$(document).on('click', '.btn-meal-export-json', function () {
var mealId = $(this).data('meal-id');
if (!mealId) {
return;
}
var safeId = String(mealId);
fetch(apiBase + '/meal_export.php?mealId=' + encodeURIComponent(safeId), { credentials: 'same-origin' })
.then(function (res) {
return res.text().then(function (text) {
var data = parseJsonResponseText(res, text);
return { res: res, data: data };
});
})
.then(function (r) {
if (!r.res.ok || !r.data || !r.data.success) {
var msg = (r.data && r.data.error) ? r.data.error : 'Export failed';
throw new Error(msg);
}
var pkg = {
version: r.data.version || '1',
meals: r.data.meals,
meal_plans: r.data.meal_plans
};
var json = JSON.stringify(pkg, null, 2);
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
var ymd = new Date().toISOString().slice(0, 10);
var fileStem = safeId.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
a.download = 'meal_export_' + fileStem + '_' + ymd + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
.catch(function (err) {
window.alert(err.message || 'Could not export recipe');
});
});
$('#mealImportFile').on('change', function () {
mealImportParsed = null;
var f = this.files && this.files[0];
var $prev = $('#mealImportPreview');
var $fb = $('#mealImportFeedback');
if ($fb.length) {
$fb.addClass('d-none').removeClass('alert-success alert-danger alert-warning');
}
$('#btnMealImportSubmit').prop('disabled', true);
if (!$prev.length) {
return;
}
if (!f) {
$prev.text('—');
return;
}
var reader = new FileReader();
reader.onload = function () {
try {
var raw = String(reader.result || '').trim();
if (raw === '') {
throw new Error('File is empty.');
}
mealImportParsed = JSON.parse(raw);
$prev.text(JSON.stringify(mealImportParsed, null, 2));
$('#btnMealImportSubmit').prop('disabled', false);
if ($fb.length) {
$fb.addClass('d-none');
}
} catch (e) {
mealImportParsed = null;
$prev.text('Invalid JSON: ' + (e.message || String(e)));
if ($fb.length) {
showAlert($fb, 'danger', 'Could not parse JSON from this file.');
$fb.removeClass('d-none');
}
$('#btnMealImportSubmit').prop('disabled', true);
}
};
reader.readAsText(f);
});
$('#btnMealImportSubmit').on('click', function () {
if (!mealImportParsed) {
return;
}
var $fb = $('#mealImportFeedback');
var $btn = $(this);
$btn.prop('disabled', true);
postJson('/meal_import.php', mealImportParsed)
.then(function (data) {
var n = typeof data.mealsUpserted === 'number' ? data.mealsUpserted : 0;
var s = typeof data.slotsApplied === 'number' ? data.slotsApplied : 0;
if ($fb.length) {
showAlert($fb, 'success', 'Imported ' + n + ' meal(s), ' + s + ' slot(s). Reloading…');
$fb.removeClass('d-none');
}
window.setTimeout(function () {
window.location.href = window.location.pathname + '?tab=meals';
}, 600);
})
.catch(function (err) {
if ($fb.length) {
showAlert($fb, 'danger', err.message || 'Import failed');
$fb.removeClass('d-none');
}
$btn.prop('disabled', false);
});
});
$('#mealImportModal').on('hidden.bs.modal', function () {
mealImportParsed = null;
$('#mealImportFile').val('');
var $prev = $('#mealImportPreview');
if ($prev.length) {
$prev.text('—');
}
$('#mealImportFeedback').addClass('d-none');
$('#btnMealImportSubmit').prop('disabled', true);
});
}
function bindCurrencyPage() {
var hasLegacyExpense = $('#expenseForm').length > 0;
var hasBanking = $('#bankTransferForm').length > 0
|| $('#bankAdjustForm').length > 0
|| $('#charityOutflowForm').length > 0;
if (!hasLegacyExpense && !hasBanking) {
return;
}
if (hasLegacyExpense) {
$('#expenseForm').on('submit', function (e) {
e.preventDefault();
var payload = {
title: $('#expense_title').val(),
description: $('#expense_description').val(),
date: $('#expense_date').val(),
value: Number($('#expense_value').val()),
assignee_id: $('#expense_assignee').val()
};
postJson('/expense_create.php', payload)
.then(function () {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'success', 'Expense recorded. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not record expense');
$fb.removeClass('d-none');
});
});
}
$('#bankTransferForm').on('submit', function (e) {
e.preventDefault();
var from = $('#transfer_from_account').val();
var to = $('#transfer_to_account').val();
if (to === 'charity' && !window.confirm('Transfers into charity cannot be moved back out. Continue?')) {
return;
}
postJson('/bank_transfer.php', {
from_account: from,
to_account: to,
amount: Number($('#transfer_amount').val()) || 0,
category: $('#transfer_category').val(),
note: $('#transfer_note').val()
})
.then(function () {
var $fb = $('#bankTransferFeedback');
showAlert($fb, 'success', 'Transfer saved. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#bankTransferFeedback');
showAlert($fb, 'danger', err.message || 'Could not transfer');
$fb.removeClass('d-none');
});
});
$('#bankAdjustForm').on('submit', function (e) {
e.preventDefault();
postJson('/bank_adjust_checking.php', {
person_id: $('#bank_adjust_person').val(),
type: $('#bank_adjust_type').val(),
amount: Number($('#bank_adjust_amount').val()) || 0,
category: $('#bank_adjust_category').val(),
note: $('#bank_adjust_note').val()
})
.then(function () {
var $fb = $('#bankAdjustFeedback');
showAlert($fb, 'success', 'Checking transaction saved. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#bankAdjustFeedback');
showAlert($fb, 'danger', err.message || 'Could not save transaction');
$fb.removeClass('d-none');
});
});
$('#charityOutflowForm').on('submit', function (e) {
e.preventDefault();
postJson('/bank_charity_outflow.php', {
person_id: $('#charity_outflow_person').val(),
amount: Number($('#charity_outflow_amount').val()) || 0,
category: $('#charity_outflow_category').val(),
note: $('#charity_outflow_note').val()
})
.then(function () {
var $fb = $('#charityOutflowFeedback');
showAlert($fb, 'success', 'Charity outflow logged. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#charityOutflowFeedback');
showAlert($fb, 'danger', err.message || 'Could not log outflow');
$fb.removeClass('d-none');
});
});
$('#donationGoalForm').on('submit', function (e) {
e.preventDefault();
postJson('/bank_set_donation_goal.php', {
person_id: $('#donation_goal_person').val(),
goal_monthly: Number($('#donation_goal_monthly').val()) || 0
})
.then(function () {
var $fb = $('#donationGoalFeedback');
showAlert($fb, 'success', 'Donation goal saved. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#donationGoalFeedback');
showAlert($fb, 'danger', err.message || 'Could not save goal');
$fb.removeClass('d-none');
});
});
$(document).on('click', '.btn-bank-reverse', function () {
var txid = $(this).data('id');
if (!txid) {
return;
}
if (!window.confirm('Create a reversal entry for this transaction? This keeps audit history intact.')) {
return;
}
postJson('/bank_reverse_transaction.php', { transaction_id: txid, note: 'Reversed by HoH' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not reverse transaction');
});
});
var periodDataEl = document.getElementById('currencyLeaderboardPeriodData');
var periodBodyEl = document.getElementById('currencyLeaderboardPeriodBody');
var periodTabsEl = document.getElementById('currencyLeaderboardPeriodTabs');
if (periodDataEl && periodBodyEl && periodTabsEl) {
var periodData = {};
try {
periodData = JSON.parse(periodDataEl.textContent || '{}');
} catch (e) {
periodData = {};
}
function renderLeaderboardPeriod(periodKey) {
var rows = Array.isArray(periodData[periodKey]) ? periodData[periodKey] : [];
var sym = periodBodyEl.getAttribute('data-currency-symbol') || '';
var html = '';
rows.forEach(function (row, idx) {
var total = Number(row.period_total || 0);
var isSelf = Boolean(row.is_self);
html += '<tr class="' + (isSelf ? 'table-primary' : '') + '">';
html += '<td class="text-muted">' + String(idx + 1) + '</td>';
html += '<td>' + escapeHtml(row.name || '') + (isSelf ? ' <span class="badge text-bg-info">You</span>' : '') + '</td>';
html += '<td class="text-end text-nowrap"><strong>' + escapeHtml(total.toFixed(2)) + '</strong> ' + escapeHtml(sym) + '</td>';
html += '</tr>';
});
periodBodyEl.innerHTML = html;
}
periodTabsEl.querySelectorAll('[data-lb-period]').forEach(function (btn) {
btn.addEventListener('click', function () {
var next = btn.getAttribute('data-lb-period') || 'weekly';
periodTabsEl.querySelectorAll('[data-lb-period]').forEach(function (b) {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
renderLeaderboardPeriod(next);
var params = new URLSearchParams(window.location.search);
params.set('tab', 'currency');
params.set('lb_period', next);
var nextUrl = window.location.pathname + '?' + params.toString();
window.history.replaceState({}, '', nextUrl);
});
});
}
}
function bindPeopleTable() {
$(document).on('click', '.btn-delete-person', function () {
var id = $(this).data('id');
if (!id || !window.confirm('Remove this person from the hub?')) {
return;
}
postJson('/people_delete.php', { id: id })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not remove');
});
});
$(document).on('click', '.btn-reset-pin', function () {
var id = $(this).data('id');
var pin = window.prompt('Enter a new PIN (4+ characters) for this Head of household.') || '';
if (pin.length < 4) {
return;
}
postJson('/people_reset_pin.php', { targetPersonId: id, newPin: pin })
.then(function () {
window.alert('PIN updated.');
})
.catch(function (err) {
window.alert(err.message || 'Could not reset PIN');
});
});
$(document).on('click', '.btn-rotate-person-nfc-token', function () {
var id = $(this).data('id');
var name = $(this).data('name') || 'this person';
if (!id || !window.confirm('Rotate NFC submitter token for ' + name + '? Existing personal NFC links will stop working.')) {
return;
}
postJson('/person_nfc_token_rotate.php', { person_id: id })
.then(function () {
window.alert('Token rotated for ' + name + '. Generate fresh chore NFC links as needed.');
})
.catch(function (err) {
window.alert(err.message || 'Could not rotate NFC token');
});
});
}
$(function () {
bindPersonaSwitcher();
bindFirstHeadForm();
bindAddPersonForm();
bindEditPersonForm();
bindFamilySettingsForm();
bindCalendarSettingsForm();
bindPeopleTable();
bindChoresPage();
bindCurrencyPage();
bindStoreManagement();
bindGroceryPage();
bindMealsPage();
});
})(jQuery);