familyHub/assets/js/main.js

843 lines
26 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);
}
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(),
week_starts_on: parseInt($('#week_starts_on').val(), 10)
};
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');
});
});
}
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,
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');
});
});
}
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();
$('#btnPushMealGrocery').on('click', function () {
var id = $(this).data('meal-id');
if (!id) {
return;
}
postJson('/meal_push_grocery.php', { id: id })
.then(function (data) {
var n = data.groceryLinesAdded != null ? data.groceryLinesAdded : 0;
showAlert($('#pushMealFeedback'), 'success', 'Added ' + n + ' line(s) to grocery (pending review).');
$('#pushMealFeedback').removeClass('d-none');
})
.catch(function (err) {
showAlert($('#pushMealFeedback'), 'danger', err.message || 'Could not push');
$('#pushMealFeedback').removeClass('d-none');
});
});
$(document).on('click', '.btn-ingredient-to-grocery', function () {
var $btn = $(this);
var mealId = $btn.data('meal-id');
var ing = $btn.data('ingredient');
var $li = $btn.closest('li');
var storeId = $li.find('.ingredient-store').val() || '';
postJson('/meal_ingredient_to_grocery.php', { mealId: mealId, ingredient: ing, storeId: storeId })
.then(function () {
window.alert('Added to grocery list.');
})
.catch(function (err) {
window.alert(err.message || 'Could not add');
});
});
$('#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);
}
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');
});
});
}
function bindCurrencyPage() {
var $form = $('#expenseForm');
if (!$form.length) {
return;
}
$form.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');
});
});
}
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');
});
});
}
$(function () {
bindPersonaSwitcher();
bindFirstHeadForm();
bindAddPersonForm();
bindFamilySettingsForm();
bindCalendarSettingsForm();
bindPeopleTable();
bindChoresPage();
bindCurrencyPage();
bindStoreManagement();
bindGroceryPage();
bindMealsPage();
});
})(jQuery);