').text(value).appendTo(tr); });
+ });
+ if (!d.rows.length) $('
').append($('
').text(PosnicPro.i18n.t('lang_no_products_expiry_review', 'No products to review.'))).appendTo('#expiry_report_rows');
+ $('#expiry_report_page').text(page + ' / ' + Math.max(1, Math.ceil(d.total / 25)));
+ $('#expiry_report_previous').prop('disabled', page <= 1);
+ $('#expiry_report_next').prop('disabled', page * 25 >= d.total);
+ }, function () { if (ticket === request) { $('#expiry_report_summary').text(''); failed(); } });
+ }
+ };
+ $(document).on('submit', '#expiry_report_filters', function (e) { e.preventDefault(); PosnicPro.expiryreport.load(1); });
+ $(document).on('click', '#expiry_report_previous', function () { PosnicPro.expiryreport.load(page - 1); });
+ $(document).on('click', '#expiry_report_next', function () { PosnicPro.expiryreport.load(page + 1); });
+ $(document).on('click', '#item_settings_save_stock', function () {
+ var button = $(this).prop('disabled', true);
+ PosnicPro.itemStockPreference.save($('#item_stock_preference').val(), function () { button.prop('disabled', false); PosnicPro.alert('success', PosnicPro.i18n.t('lang_item_settings_saved', 'Item settings saved.')); }, function () { button.prop('disabled', false); });
+ });
+ $(document).on('click', '#expiry_reminders_save', function () {
+ var days = Number($('#expiry_reminders_days').val()), button = $(this);
+ if (!Number.isInteger(days) || days < 0 || days > 365 || $('#expiry_reminders_days').val() === '') { PosnicPro.alert('error', PosnicPro.i18n.t('lang_expiry_days_invalid', 'Choose a reminder period between 0 and 365 days.')); return; }
+ button.prop('disabled', true);
+ PosnicPro.put({url:'items/expiryReminders/preference',data:JSON.stringify({enabled:$('#expiry_reminders_enabled').prop('checked'),days:days})},function(r){button.prop('disabled',false);if(r.type!=='success'){failed();return;}PosnicPro.alert('success',PosnicPro.i18n.t('lang_expiry_preferences_saved','Expiry reminder settings saved.'));PosnicPro.expiryReminders.refresh();},function(){button.prop('disabled',false);failed();});
+ });
+})();
diff --git a/languages/_english.json b/languages/_english.json
index b156a190..047c8f96 100644
--- a/languages/_english.json
+++ b/languages/_english.json
@@ -230,6 +230,7 @@
"lang_ai_spent_short": "Spent so far",
"lang_ai_title": "Let the software write and read for you",
"lang_align_left": "Left",
+ "lang_all": "All",
"lang_all_apps": "All apps",
"lang_all_changes": "All changes",
"lang_all_customers": "All customers",
@@ -918,7 +919,6 @@
"lang_could_not_save_the_signature": "Could not save the signature",
"lang_could_not_save_the_voice_key": "Could not save the voice key",
"lang_could_not_save_the_voice_settings": "Could not save the voice settings",
- "lang_could_not_send_the_test": "Could not send the test.",
"lang_could_not_stop_that_phone": "Could not stop that phone.",
"lang_could_not_switch_sample_data_off": "Could not switch sample data off",
"lang_could_not_update_the_document": "Could not update the document.",
@@ -1038,6 +1038,7 @@
"lang_day_end": "Day-End",
"lang_day_end_summary": "Day-End Summary",
"lang_days_ago": "days ago",
+ "lang_days_ahead": "Days ahead",
"lang_days_remaining": "days remaining",
"lang_dayss": "day)",
"lang_deactivate_title": "InActive",
@@ -1314,6 +1315,7 @@
"lang_each_item_can_have_only_one_price_in_this": "Each item can have only one price in this list.",
"lang_ean13": "EAN13",
"lang_ean8": "EAN8",
+ "lang_earliest_expiry_first": "Earliest expiry first",
"lang_earn_multiplier": "Earn multiplier",
"lang_earn_rate_needs_a_points_value_and_an_amou": "Earn rate needs a points value and an amount greater than zero.",
"lang_earning": "Earning",
@@ -1556,7 +1558,9 @@
"lang_expiry_preferences_saved": "Expiry reminder settings saved.",
"lang_expiry_reminders": "Expiry reminders",
"lang_expiry_reminders_help": "Reminders appear in the POS bell for products with stock and a saved expiry date. Dates use the shop timezone. No email or WhatsApp messages are sent.",
- "lang_expiry_reminders_off": "Expiry reminders are off.",
+ "lang_expiry_report": "Expiring products report",
+ "lang_expiry_report_help": "Products with stock and a saved expiry date. Available even when reminders are off.",
+ "lang_expiry_report_short": "Expiry report",
"lang_expiry_title": "Items Expiry Date",
"lang_export_csv": "Export CSV",
"lang_export_excel": "Export Excel",
@@ -1980,6 +1984,9 @@
"lang_item_saved": "Item saved",
"lang_item_sell_out_of_stock": "Sell when out of stock",
"lang_item_service_unit": "Item service unit",
+ "lang_item_settings": "Item settings",
+ "lang_item_settings_admin": "An administrator can change item settings.",
+ "lang_item_settings_saved": "Item settings saved.",
"lang_item_stock_behavior": "Stock & selling",
"lang_item_stock_variants_help": "For variants, enter opening stock in each variant row.",
"lang_item_title": "Item",
@@ -2157,6 +2164,7 @@
"lang_last_purchase": "Last purchase",
"lang_last_used": "Last used",
"lang_lastname": "Last Name",
+ "lang_latest_expiry_first": "Latest expiry first",
"lang_lato": "Lato",
"lang_leave_a_role_list_empty_to_rely_on_the_nor": "Leave a role list empty to rely on the normal Posnic permissions.",
"lang_leave_all_unchecked_to_serve_this_dish_all": "Leave all unchecked to serve this dish all day.",
@@ -3195,6 +3203,13 @@
"lang_purchaseprint": "Receiving Print",
"lang_purchases": "purchases",
"lang_purchse_title": "Purchase",
+ "lang_push_blocked": "Notifications are blocked. Allow notifications in this site’s browser settings, then reload.",
+ "lang_push_failed": "Could not enable or send device notifications. Check browser permissions and your connection, then try again. The POS bell still works.",
+ "lang_push_permission_needed": "Notifications were not enabled. Allow the browser permission to receive device notifications.",
+ "lang_push_reload": "Notification setup is not ready. Reload this page and try again.",
+ "lang_push_setting_up": "Setting up device notifications…",
+ "lang_push_test_sent": "Test notification sent. If it is not visible, check your device notification settings.",
+ "lang_push_unsupported": "Device notifications are unavailable in this browser. Use Chrome or Edge over HTTPS. The POS bell still works.",
"lang_qe_disc_type": "Qe disc type",
"lang_qe_valid_until": "Qe valid until",
"lang_qr_code_generation_timeout_please_try_agai": "QR code generation timeout. Please try again.",
@@ -3461,6 +3476,7 @@
"lang_reference_utr_optional": "Reference / UTR (optional)",
"lang_referrals": "Referrals",
"lang_referred_by": "Referred by",
+ "lang_refresh": "Refresh",
"lang_refresh_failed": "Refresh failed",
"lang_refresh_failed_try_again": "Refresh failed. Try again.",
"lang_refresh_orders": "Refresh orders",
@@ -3916,6 +3932,7 @@
"lang_skip_items_whose_new_price_would_go_above": "Skip items whose new price would go above MRP",
"lang_skip_setup_and_start_selling": "Skip setup and start selling",
"lang_skipped": "Skipped",
+ "lang_sku": "SKU",
"lang_sku_barcode": "SKU / Barcode",
"lang_sku_item_code": "SKU / item code",
"lang_sku_title": "SKU",
@@ -4279,6 +4296,7 @@
"lang_to_date": "To date",
"lang_to_email_address": "To email address",
"lang_to_use_questions_insights_actions_and_sche": "to use questions, insights, actions and schedules.",
+ "lang_today_and_upcoming": "Today and upcoming",
"lang_token": "Token",
"lang_token_copied": "Token copied",
"lang_tomorrow": "Tomorrow",
diff --git a/languages/nl.json b/languages/nl.json
index 93b14f51..2e722357 100644
--- a/languages/nl.json
+++ b/languages/nl.json
@@ -4627,5 +4627,25 @@
"lang_no_products_expiry_review": "Geen producten om te controleren.",
"lang_expiry_days_invalid": "Kies een herinneringsperiode tussen 0 en 365 dagen.",
"lang_expiry_preferences_saved": "Instellingen voor vervalherinneringen opgeslagen.",
- "lang_expired": "Verlopen"
+ "lang_expired": "Verlopen",
+ "lang_expiry_report": "Rapport vervallende producten",
+ "lang_item_settings": "Artikelinstellingen",
+ "lang_expiry_report_help": "Producten met voorraad en een opgeslagen vervaldatum. Ook beschikbaar als herinneringen uitstaan.",
+ "lang_days_ahead": "Dagen vooruit",
+ "lang_refresh": "Vernieuwen",
+ "lang_sku": "SKU",
+ "lang_all": "Alle",
+ "lang_today_and_upcoming": "Vandaag en binnenkort",
+ "lang_earliest_expiry_first": "Vroegste vervaldatum eerst",
+ "lang_latest_expiry_first": "Laatste vervaldatum eerst",
+ "lang_push_unsupported": "Apparaatmeldingen zijn niet beschikbaar in deze browser. Gebruik Chrome of Edge via HTTPS. De POS-meldingen blijven werken.",
+ "lang_push_blocked": "Meldingen zijn geblokkeerd. Sta meldingen toe in de browserinstellingen voor deze site en laad opnieuw.",
+ "lang_push_reload": "Meldingen zijn nog niet gereed. Laad deze pagina opnieuw en probeer het nogmaals.",
+ "lang_push_setting_up": "Apparaatmeldingen instellen…",
+ "lang_push_permission_needed": "Meldingen zijn niet ingeschakeld. Geef toestemming in de browser om apparaatmeldingen te ontvangen.",
+ "lang_push_test_sent": "Testmelding verzonden. Controleer de meldingsinstellingen van uw apparaat als deze niet zichtbaar is.",
+ "lang_push_failed": "Apparaatmeldingen konden niet worden ingeschakeld of verzonden. Controleer de browsermachtigingen en uw verbinding en probeer opnieuw. De POS-meldingen blijven werken.",
+ "lang_item_settings_admin": "Een beheerder kan artikelinstellingen wijzigen.",
+ "lang_item_settings_saved": "Artikelinstellingen opgeslagen.",
+ "lang_expiry_report_short": "Vervalrapport"
}
diff --git a/tests/device-notifications.test.js b/tests/device-notifications.test.js
new file mode 100644
index 00000000..c71f6b16
--- /dev/null
+++ b/tests/device-notifications.test.js
@@ -0,0 +1,14 @@
+const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm'),fs=require('node:fs'),path=require('node:path');
+function fixture(options={}) {
+ const elements={},calls=[],alerts=[];const $=selector=>elements[selector] ||= {length:1,values:{},show(){return this;},prop(k,v){this.values[k]=v;return this;},text(v){this.value=v;return this;},data(k,v){if(arguments.length===1)return this.values[k];this.values[k]=v;return this;}};
+ const subscription={endpoint:'https://push.example/device',toJSON(){return {endpoint:this.endpoint};}};
+ const reg={active:{},pushManager:{getSubscription:async()=>options.existing?subscription:null,subscribe:async()=>{if(options.subscribeError)throw Error('browser');return subscription;}}};
+ const Notification={permission:options.permission||'granted',requestPermission:async()=>options.grant||'granted'};
+ const PosnicPro={i18n:{t:(k,f)=>f},alert:(type,text)=>alerts.push({type,text}),get:(p,done)=>{calls.push(p.url);done({type:'success',data:{key:'AQID'}});},post:(p,done)=>{calls.push(p.url);done(p.url===options.fail?{type:'error'}:{type:'success',data:{sent:options.sent??1}});}};
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../frontend/static/script/js/modules/js/device-notifications.js'),'utf8'),{PosnicPro,$,window:{isSecureContext:true,Notification,PushManager:{}},Notification,navigator:{serviceWorker:{getRegistration:async()=>options.noWorker?null:reg}},setTimeout,clearTimeout,Uint8Array,atob:s=>Buffer.from(s,'base64').toString('binary')});
+ return {api:PosnicPro.deviceNotifications,calls,alerts,elements,$};
+}
+test('notification setup persists before reporting success',async()=>{const f=fixture();await f.api.click();assert.deepEqual(f.calls,['push/key','push/subscribe']);assert.equal(f.alerts.at(-1).type,'success');});
+test('notification registration and browser failures are visible, never false successes',async()=>{for(const options of [{fail:'push/subscribe'},{subscribeError:true},{noWorker:true}]){const f=fixture(options);await f.api.click();assert.equal(f.alerts.at(-1).type,'error');assert.equal(f.$('#bell_feed_push').values.disabled,false);}});
+test('blocked permission explains how to recover without a server request',async()=>{const f=fixture({permission:'denied'});await f.api.click();assert.equal(f.calls.length,0);assert.match(f.$('#bell_push_status').value,/blocked/);assert.equal(f.$('#bell_feed_push').values.disabled,true);});
+test('existing browser subscription repairs server registration before testing delivery',async()=>{const f=fixture({existing:true,sent:0});f.$('#bell_feed_push').data('subscribed',true);await f.api.click();assert.deepEqual(f.calls,['push/key','push/subscribe','push/test']);assert.equal(f.alerts.at(-1).type,'error');});