- Added banking mode with checking, savings, and charity accounts, including auto-split options for income. - Introduced banking transaction management, including transfers and charity outflows. - Updated family settings to allow configuration of banking features and interest rates. - Enhanced data export functionality to include bank transactions. - Improved user interface to display banking information and donation goals. - Updated documentation to reflect new banking features and settings.
1222 lines
41 KiB
JavaScript
1222 lines
41 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';
|
|
|
|
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.json().then(function (data) {
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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 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 $form = $('#choreForm');
|
|
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');
|
|
});
|
|
});
|
|
|
|
$(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');
|
|
});
|
|
});
|
|
}
|
|
|
|
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 || !el.textContent) {
|
|
return [];
|
|
}
|
|
try {
|
|
return JSON.parse(el.textContent);
|
|
} 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.json().then(function (data) {
|
|
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 {
|
|
mealImportParsed = JSON.parse(String(reader.result || ''));
|
|
$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');
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
bindFamilySettingsForm();
|
|
bindCalendarSettingsForm();
|
|
bindPeopleTable();
|
|
bindChoresPage();
|
|
bindCurrencyPage();
|
|
bindStoreManagement();
|
|
bindGroceryPage();
|
|
bindMealsPage();
|
|
});
|
|
})(jQuery);
|