diff --git a/api/src/controllers/settings.controller.js b/api/src/controllers/settings.controller.js index 5f854e69a..1cc86e877 100644 --- a/api/src/controllers/settings.controller.js +++ b/api/src/controllers/settings.controller.js @@ -1260,7 +1260,7 @@ class SettingController extends BaseController { async getTaxProfile(req, res) { try { const settingModel = this.createModelWithContext(req); - const result = await settingModel.getTaxProfileModel(); + const result = await settingModel.getTaxProfileModel(req.query?.country); const code = result.status ? 200 : 400; return res.status(code).json({ type: result.status ? 'success' : 'error', diff --git a/api/src/models/branch.model.js b/api/src/models/branch.model.js index dd0c46e19..de8b6a032 100644 --- a/api/src/models/branch.model.js +++ b/api/src/models/branch.model.js @@ -1042,6 +1042,7 @@ class BranchModel { */ async createBranch(data, user = {}) { try { + const regional = require('../services/branch-regional')(data); // 1. Check duplicate const duplicate = await this.model.findOne({ branch_name: { $regex: new RegExp(`^${data.name}$`, 'i') }, @@ -1218,6 +1219,7 @@ class BranchModel { client_dateformat: 'dd/mm/yyyy', server_dateformat: 'd/m/Y', dateformat_text: '01/01/2018 -- dd/mm/yyyy', + ...regional, time_format: 'enable', payment_gateway: [], online_ordering: onlineOrdering.defaultConfig(), diff --git a/api/src/models/setting.model.js b/api/src/models/setting.model.js index ba1b47a66..151f60a99 100644 --- a/api/src/models/setting.model.js +++ b/api/src/models/setting.model.js @@ -3878,8 +3878,17 @@ class SettingModel extends BaseModel { * is CALLED, how its registration number is labelled and shaped, how it * displays. Presentation only - rates and math never travel here. */ - async getTaxProfileModel() { + async getTaxProfileModel(country) { try { + // Reference metadata only; this does not read another branch's settings. + if (typeof country === 'string' && country.trim()) { + const countries = require('../json/countries.json'); + const entry = countries.countries.find((item) => item.value === country.trim()); + const { code, profile } = require('../services/tax-profiles').profileForBranch({ + sortname: entry?.sortname || '', + }); + return { status: true, data: { code, country, registration: profile.registration } }; + } const branchCollection = await this.getCollection(); const branch = await branchCollection.findOne({ _id: this.normalizeId(this.branchId), diff --git a/api/src/services/branch-regional.js b/api/src/services/branch-regional.js new file mode 100644 index 000000000..6d827fd91 --- /dev/null +++ b/api/src/services/branch-regional.js @@ -0,0 +1,52 @@ +const { countries } = require('../json/countries.json'); +const { currency } = require('../json/currency.json'); +const currencyByCountry = require('../json/country_currency.json'); +const taxProfiles = require('./tax-profiles'); + +// Whitelist regional fields; derived labels and symbols always come from reference data. +module.exports = function branchRegional(data) { + const country = countries.find( + (item) => item.value === data.country || item.sortname === data.country + ); + const selected = data.currency_text + ? currency.find((item) => item.value === data.currency_text) + : currency.find((item) => item.text === currencyByCountry[country?.sortname]); + if (data.currency_text && !selected) throw new Error('Choose a valid currency'); + const unit = selected || currency.find((item) => item.text === 'INR'); + const timezone = data.time_zone || (country?.sortname === 'IN' ? 'Asia/Kolkata' : 'UTC'); + try { + new Intl.DateTimeFormat('en', { timeZone: timezone }); + } catch (cause) { + throw new Error('Choose a valid time zone', { cause }); + } + const formats = { + 'dd/mm/yyyy': 'd/m/Y', + 'yyyy/mm/dd': 'Y/m/d', + 'mm/dd/yyyy': 'm/d/Y', + 'D mm/dd': 'D m/d', + 'MM dd yyyy': 'M d y', + 'd MM yy': 'd M y', + 'D d MM yy': 'D d M y', + }; + const date = data.client_dateformat || 'dd/mm/yyyy'; + if (!formats[date]) throw new Error('Choose a valid date format'); + const tax = typeof data.branch_gstin_number === 'string' ? data.branch_gstin_number.trim() : ''; + const { profile } = taxProfiles.profileForBranch({ sortname: country?.sortname }); + if (tax.length > 100 || (tax && !taxProfiles.registrationValid(profile, tax))) { + throw new Error('Enter a valid ' + profile.registration.label); + } + return { + currency: unit.symbol, + currency_text: unit.value, + currency_type: data.currency_type === unit.text ? unit.text : unit.symbol, + currency_value: [{ currency_text: unit.text, currency_sign: unit.symbol }], + time_zone: timezone, + client_dateformat: date, + server_dateformat: formats[date], + dateformat_text: date, + branch_gstin_number: tax, + printing_address: (data.printing_address || data.address || '').trim().slice(0, 500), + website: typeof data.website === 'string' ? data.website.trim().slice(0, 50) : '', + pincode: typeof data.pincode === 'string' ? data.pincode.trim().slice(0, 15) : '', + }; +}; diff --git a/api/tests/unit/models/branch.model.test.js b/api/tests/unit/models/branch.model.test.js index c4499772e..a3b36036c 100644 --- a/api/tests/unit/models/branch.model.test.js +++ b/api/tests/unit/models/branch.model.test.js @@ -980,6 +980,8 @@ describe('BranchModel.createBranch', () => { expect(r.status).toBe(true); expect(r.data.insertId).toBe(branchId.toString()); expect(mockModel.create.mock.calls[0][0].city).toBe('Kochi'); + expect(mockModel.create.mock.calls[0][0].branch_gstin_number).toBe(''); + expect(mockModel.create.mock.calls[0][0].currency_value[0].currency_text).toBe('INR'); }); test('returns branchList array on success', async () => { diff --git a/api/tests/unit/services/branch-regional.test.js b/api/tests/unit/services/branch-regional.test.js new file mode 100644 index 000000000..c7c87154d --- /dev/null +++ b/api/tests/unit/services/branch-regional.test.js @@ -0,0 +1,37 @@ +const regional = require('../../../src/services/branch-regional'); +const { currency } = require('../../../src/json/currency.json'); +describe('branch regional creation', () => { + test('blank tax number is optional in every supported country', () => { + for (const country of ['India', 'United Kingdom', 'Germany', 'United Arab Emirates']) + expect(regional({ country, branch_gstin_number: ' ' }).branch_gstin_number).toBe(''); + }); + test('country picks currency without defaulting international branches to INR', () => { + expect(regional({ country: 'United Kingdom' }).currency_value[0].currency_text).toBe('GBP'); + expect(regional({ country: 'Germany' }).currency_value[0].currency_text).toBe('EUR'); + }); + test('explicit settings survive creation with canonical currency metadata', () => { + const usd = currency.find((x) => x.text === 'USD'); + const result = regional({ + country: 'United States', + currency_text: usd.value, + currency_type: 'USD', + time_zone: 'America/New_York', + client_dateformat: 'mm/dd/yyyy', + printing_address: ' Receipt address ', + pincode: 'SW1A 1AA', + }); + expect(result).toMatchObject({ + currency_type: 'USD', + time_zone: 'America/New_York', + client_dateformat: 'mm/dd/yyyy', + server_dateformat: 'm/d/Y', + printing_address: 'Receipt address', + pincode: 'SW1A 1AA', + }); + }); + test('invalid configured values fail before creating a branch', () => { + expect(() => regional({ country: 'India', branch_gstin_number: 'not-gstin' })).toThrow(/GST/); + expect(() => regional({ country: 'India', currency_text: 'invented' })).toThrow(/currency/); + expect(() => regional({ country: 'India', time_zone: 'not-a-zone' })).toThrow(/time zone/); + }); +}); diff --git a/docs/RECORD_FORM_COMPLETENESS.md b/docs/RECORD_FORM_COMPLETENESS.md new file mode 100644 index 000000000..e8a5bc639 --- /dev/null +++ b/docs/RECORD_FORM_COMPLETENESS.md @@ -0,0 +1,21 @@ +# Record form completeness + +New Unit uses the shared compact dialog. Supplier name/phone and user password/confirmation align in pairs. Missing user permissions are checked inside the user form and reveal the Access tab. Category shape previews use the selected swatch. Checkout settings have a readable maximum width. + +New Branch exposes currency, currency display, timezone, date format, optional country-specific tax registration, printing address, website and postal code alongside the existing registers/sharing controls. The API derives currency metadata from reference data and validates regional values before creation. Branch tax validation is isolated from the India-only customer/supplier GST validators. The existing country profile registry remains the source of format rules. + +State selection preserves saved values and ignores stale responses. No state is inferred from a city. The tested live shop has an empty saved state and needs its correct state saved on the branch. + +## Data and synchronization + +All regional values use existing fields on the existing tenant-scoped `branches` collection. Creation still uses BranchModel's existing identifiers, timestamps, user access update and seed/write path. The POS collection manifest, Gateway collection registry and agent config already include `branches` as a global-within-tenant batch lane. There are no new collections or credentials to replicate, no migration, and no change to conflict, deletion or attachment transport. Existing authorized tills receive these fields in the existing whole-document branch transfer. New web-created records become available to offline tills upon reconnection through that existing lane. No Gateway/agent rollout is required. Physical cross-till offline/reconnect delivery was not exercised in this web release; existing sync classification/protocol checks ran in the standard suite. + +## Validation + +- `npm run check`: all eight checks passed; 4,309 desktop tests passed, five skipped. +- Full API suite: 528 suites passed; 12,891 tests passed, 13 skipped. +- Focused form regressions: 12 passed. +- Branch model/regional focused suite: 173 passed. +- Frontend asset build passed. +- Offscreen browser previews covered light/dark themes, phone/select widgets and mobile overflow. +- Existing receipt/printer behavior is unchanged; standard desktop printer regression tests passed. diff --git a/frontend/modals/branch.html b/frontend/modals/branch.html index cbb0b813c..b2dd3ec7d 100644 --- a/frontend/modals/branch.html +++ b/frontend/modals/branch.html @@ -7,20 +7,81 @@

-
-
+
+ +
Regional & tax
+ + + + +
+ + +
+ + +
+ + + + +
+ + +
+ + +
+ + +
+ + +
-
+
@@ -57,14 +118,14 @@

-

-
+
+
-
+
-
+
@@ -160,7 +221,7 @@

Branch D

-
+
@@ -222,8 +283,8 @@

Branch D

- - + +
@@ -277,8 +338,8 @@

Branch D

- - + +
diff --git a/frontend/modules/branchEdit.html b/frontend/modules/branchEdit.html index 5993459df..46e3cfb6f 100644 --- a/frontend/modules/branchEdit.html +++ b/frontend/modules/branchEdit.html @@ -173,7 +173,7 @@
Pincode
@@ -246,8 +246,7 @@
GSTIN Number + name="branch_gstin_number" id="branch_gstin_number" maxlength="100" placeholder="Enter the GSTIN" data-t-placeholder="lang_enter_the_gstin" autocomplete="off">
diff --git a/frontend/modules/units.html b/frontend/modules/units.html index 04c682f10..f2c5d9347 100644 --- a/frontend/modules/units.html +++ b/frontend/modules/units.html @@ -55,3 +55,23 @@

Units

+ +
+
+

Units

+ Close +
+
+ +
+
+
+
+ +
+ + +
+ +
+
diff --git a/frontend/static/script/js/core/record-workspace.js b/frontend/static/script/js/core/record-workspace.js index 71a44b712..926b4eca1 100644 --- a/frontend/static/script/js/core/record-workspace.js +++ b/frontend/static/script/js/core/record-workspace.js @@ -96,6 +96,7 @@ var index = panels.findIndex(function (p) { return p.contains(input); }); if (index >= 0) show(index); } + $(form).on('record-reveal', function (event, input) { reveal(input); }); form.addEventListener('invalid', function (event) { reveal(event.target); }, true); $(form).on('invalid-form.recordWorkspace', function (event, validator) { if (validator.errorList.length) reveal(validator.errorList[0].element); @@ -284,7 +285,7 @@ initialized = true; contacts('customer', 'customer_add_form'); contacts('supplier', 'supplier_add'); - ['users', 'branch', 'category', 'customercategory', 'variant', 'tax', 'taxgroup', 'payment', 'denomcash', 'expense', 'tableorder'].forEach(function (name) { + ['users', 'branch', 'category', 'customercategory', 'variant', 'unit', 'tax', 'taxgroup', 'payment', 'denomcash', 'expense', 'tableorder'].forEach(function (name) { var panel = document.getElementById('infobar-settings-sidebar-' + name); decorateDialog(panel); if (panel && !['users', 'branch', 'category'].includes(name)) panel.classList.add('record-dialog-small'); @@ -298,8 +299,8 @@ branch.prepend(identity.parentElement, location.parentElement); var branchActions = branch.querySelector('.record-actions'); if (branchActions) branch.appendChild(branchActions); - formTabs(branch, [['lang_shopinfo', 'Branch / Outlet Info'], ['lang_record_setup', 'Registers & setup']], function (child) { - return child === identity.parentElement || child === location.parentElement ? 0 : 1; + formTabs(branch, [['lang_shopinfo', 'Branch / Outlet Info'], ['lang_record_regional', 'Regional & tax'], ['lang_record_setup', 'Registers & setup']], function (child) { + return child === identity.parentElement || child === location.parentElement ? 0 : child.id === 'branch_regional_fields' ? 1 : 2; }); } var user = document.getElementById('user_form_upload'); diff --git a/frontend/static/script/js/modules/js/branches.js b/frontend/static/script/js/modules/js/branches.js index ea7a3bf4a..cfbb5a24b 100644 --- a/frontend/static/script/js/modules/js/branches.js +++ b/frontend/static/script/js/modules/js/branches.js @@ -27,6 +27,45 @@ PosnicPro.branches = { PosnicPro.branches.branchClearform(); } PosnicPro.branches.branchAction = 'add'; + PosnicPro.branches.loadRegionalDefaults(); + }, + loadRegionalDefaults: function () { + var generation = (this.regionalRequest || 0) + 1; + this.regionalRequest = generation; + $('#branchSubmitForm').prop('disabled', true); + var get = function (url, data) { + return new Promise(function (resolve, reject) { PosnicPro.get({ url: url, data: data || {} }, resolve, reject); }); + }; + Promise.all([ + get('branches/getBranchDetails', { id: PosnicPro.local.get('branch_id_set') }), + get('setting/getJSONCurrency'), get('setting/getJSONTimeZone') + ]).then(function (responses) { + if (PosnicPro.branches.regionalRequest !== generation) return; + var shop = responses[0].data; + var currencies = responses[1].data.currency; + var select = $('#branch_currency').empty(); + currencies.forEach(function (unit) { select.append(new Option(unit.value, unit.value)); }); + select.val(shop.currency_text); + var changeCurrency = function () { + var unit = currencies.find(function (item) { return item.value === select.val(); }); + if (!unit) return; + $('#branch_currency_type').empty().append(new Option(unit.symbol, unit.symbol), new Option(unit.text, unit.text)); + }; + select.off('change.regional').on('change.regional', changeCurrency); + changeCurrency(); + $('#branch_currency_type').val(shop.currency_type); + var timezone = $('#branch_timezone').empty(); + responses[2].data.forEach(function (zone) { timezone.append(new Option(zone.value, zone.text)); }); + if (!Array.from(timezone[0].options).some(function (option) { return option.value === shop.time_zone; })) timezone.append(new Option(shop.time_zone, shop.time_zone)); + timezone.val(shop.time_zone); + $('#branch_dateformat').val(shop.client_dateformat || 'dd/mm/yyyy'); + $('#branch_tax_number,#branch_printing_address,#branch_website,#branch_pincode').val(''); + PosnicPro.settings.applyTaxProfile($('#branch_country').val(), '#branch_tax_number'); + $('#branchSubmitForm').prop('disabled', false); + }).catch(function () { + if (PosnicPro.branches.regionalRequest !== generation) return; + PosnicPro.alert('error', PosnicPro.i18n.t('lang_could_not_load_settings_please_retry', 'Could not load settings. Please retry.')); + }); }, /* * Edit is a FULL PAGE now (modules/branchEdit.html): branch identity, @@ -73,10 +112,8 @@ PosnicPro.branches = { // Selects fill after their (async) option lists land. setTimeout(function () { $('#setting_country').val(d.country).trigger('change.select2'); - PosnicPro.settings.loadSelectSettingState(d.country_id); - setTimeout(function () { - $('#setting_state').val(d.state).trigger('change.select2'); - }, 400); + PosnicPro.settings.loadSelectSettingState(d.country_id, d.state); + PosnicPro.settings.applyTaxProfile(d.country); $('#currency_setting').val(d.currency_text).trigger('change.select2'); var cv = (d.currency_value && d.currency_value[0]) || { currency_text: 'INR', currency_sign: '₹' }; $('#currencyText').val(cv.currency_sign); @@ -626,6 +663,7 @@ PosnicPro.branches = { PosnicPro.branches.sharingRow(true); }, branchClearform: function () { + PosnicPro.branches.loadRegionalDefaults(); PosnicPro.branches.loadSelectBranchState(PosnicPro.local.get('countryid'), 'clear'); $('#branch_add_form')[0].reset(); $('.register-wrapper .register-fields .register-input:nth-child(n+2)').remove(); @@ -654,8 +692,9 @@ PosnicPro.branches = { (response.data.stateJsonArray || []).forEach(function (state) { stateSelect.append(new Option(state, state)); }); - var found = Array.from(stateSelect[0].options).some(function (option) { return option.value === selectedState; }); - stateSelect.val(found ? selectedState : '').trigger('change.select2'); + var match = Array.from(stateSelect[0].options).find(function (option) { return option.value.trim().toLowerCase() === String(selectedState || '').trim().toLowerCase(); }); + if (!match && selectedState) stateSelect.append(new Option(selectedState, selectedState)); + stateSelect.val(match ? match.value : (selectedState || '')).trigger('change.select2'); (PosnicPro.branches.branch_phone || { setCountry: function () {} }).setCountry(response.data.countrySortName); }); }, @@ -700,6 +739,7 @@ PosnicPro.branches = { state: { required: true }, + branch_gstin_number: { branchTaxRegistration: true, maxlength: 100 }, "branchregister[0]": { minlength: 3, maxlength: 20 @@ -826,6 +866,7 @@ $('#branch_country').one('change', function () { var countrySelect = $('#branch_country'); countrySelect.on('select2:select', function (e) { var data = e.params.data; + PosnicPro.settings.applyTaxProfile(countrySelect.val(), '#branch_tax_number'); PosnicPro.branches.loadSelectBranchState(data.element.attributes['data-setting-id'].value, 'onChange'); }); }); diff --git a/frontend/static/script/js/modules/js/categories.js b/frontend/static/script/js/modules/js/categories.js index d3e6cbe9a..2451653f9 100644 --- a/frontend/static/script/js/modules/js/categories.js +++ b/frontend/static/script/js/modules/js/categories.js @@ -1,6 +1,7 @@ PosnicPro.categories = { setCatTileColor: function (color) { $('#category_tile_color').val(color || ''); + $('#category_tile_shapes').css('--record-shape-color', color || 'var(--theme-primary-color)'); $('#category_tile_swatches .tile-swatch').removeClass('is-picked').attr('aria-pressed', 'false').filter(function () { return ($(this).data('color') || '') === (color || ''); }).addClass('is-picked').attr('aria-pressed', 'true'); diff --git a/frontend/static/script/js/modules/js/customers.js b/frontend/static/script/js/modules/js/customers.js index 43bd239cc..4d79ddd4d 100644 --- a/frontend/static/script/js/modules/js/customers.js +++ b/frontend/static/script/js/modules/js/customers.js @@ -795,8 +795,9 @@ PosnicPro.customers = { (response.data.stateJsonArray || []).forEach(function (state) { stateSelect.append(new Option(state, state)); }); - var found = Array.from(stateSelect[0].options).some(function (option) { return option.value === selectedState; }); - stateSelect.val(found ? selectedState : '').trigger('change.select2'); + var match = Array.from(stateSelect[0].options).find(function (option) { return option.value.trim().toLowerCase() === String(selectedState || '').trim().toLowerCase(); }); + if (!match && selectedState) stateSelect.append(new Option(selectedState, selectedState)); + stateSelect.val(match ? match.value : (selectedState || '')).trigger('change.select2'); (PosnicPro.customers.customer_phone || { setCountry: function () {} }).setCountry(response.data.countrySortName); if (action === 'edit' && savedPhone !== undefined) PosnicPro.customers.setPhone(savedPhone); }, function (xhr) { @@ -841,7 +842,7 @@ PosnicPro.customers = { }, gstin_number: { required: true, - gst: true, + customersGst: true, minlength: 15, maxlength: 15 } @@ -884,8 +885,8 @@ PosnicPro.customers = { return !!input && input.isValidNumber() === true; }, "Enter a valid phone number"); - jQuery.validator.addMethod("gst", function (value, element) { - return /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(value); + jQuery.validator.addMethod("customersGst", function (value, element) { + return this.optional(element) || /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(value); }, "Enter a valid GSTIN"); $("#customer_add_form").submit(function (event) { diff --git a/frontend/static/script/js/modules/js/settings.js b/frontend/static/script/js/modules/js/settings.js index f37e83f5e..72585ea62 100644 --- a/frontend/static/script/js/modules/js/settings.js +++ b/frontend/static/script/js/modules/js/settings.js @@ -142,18 +142,18 @@ PosnicPro.settings = { * India keeps exactly its current look; the gst_action machinery is * untouched. Presentation only, and failure leaves things as they are. */ - applyTaxProfile: function () { - PosnicPro.get({ url: 'setting/taxProfile', data: {} }, function (r) { + applyTaxProfile: function (country, selector) { + var input = $(selector || '#branch_gstin_number'); + country = country || $('#setting_country').val() || PosnicPro.local.get('country_setting'); + input.data('tax-country', country).removeData('tax-regex').removeAttr('minlength').attr('maxlength', 100); + PosnicPro.get({ url: 'setting/taxProfile', data: { country: country } }, function (r) { var p = r && r.data; - if (!p || !p.registration) { return; } - PosnicPro.settings._taxProfile = p; + if (!p || !p.registration || input.data('tax-country') !== country) return; + input.data('tax-regex', p.registration.regex || ''); + input.closest('.form-group').find('label lang').text(p.registration.label); + input.attr('placeholder', p.registration.label); $('.branch-gstin-hide-show').show(); - $('.branch-gstin-hide-show label lang').text(p.registration.label); - $('#branch_gstin_number').attr('placeholder', 'Enter the ' + p.registration.label); - if (!p.registration.regex) { - $('#branch_gstin_number').removeAttr('minlength').attr('maxlength', 30); - } - }, function () { /* presentation only - never disturb the page */ }); + }); }, /* * The Tax System card (PURCHASE_TAX_PLAN G6). One GET fills everything: @@ -2864,24 +2864,21 @@ loadSelectSettingCountry: function (force) { PosnicPro.alert(response.type, response.message); }); }, - loadSelectSettingState: function (id) { + loadSelectSettingState: function (id, selectedState) { + var request = (this.stateRequest || 0) + 1; this.stateRequest = request; + if (selectedState === undefined) selectedState = $('#setting_country').val() === PosnicPro.local.get('country_setting') ? PosnicPro.local.get('state_setting') : ''; var stateSelect = $('#setting_state'); var params = { url: 'setting/getJSONState', data: { id: id } }; PosnicPro.get(params, function (response) { - stateSelect.empty(); - suggestions: $.map(response.data['stateJsonArray'], function (dataItem) { - var options; - options += ''; - stateSelect.append(options).trigger('change'); - }); - if (PosnicPro.local.get("country_setting") === PosnicPro.local.get("country_value")) { - stateSelect.val(PosnicPro.local.get('state_setting')).trigger('change.select2'); - } else { - $('#setting_state option:eq(0)').prop('selected', true); - } + if (PosnicPro.settings.stateRequest !== request) return; + stateSelect.empty().append(new Option('', '')); + (response.data.stateJsonArray || []).forEach(function (state) { stateSelect.append(new Option(state, state)); }); + var match = Array.from(stateSelect[0].options).find(function (option) { return option.value.trim().toLowerCase() === String(selectedState || '').trim().toLowerCase(); }); + if (!match && selectedState) stateSelect.append(new Option(selectedState, selectedState)); + stateSelect.val(match ? match.value : (selectedState || '')).trigger('change.select2'); (PosnicPro.settings.store_telephone || { setCountry: function () {} }).setCountry(response.data['countrySortName']); }, function (xhr) { var response = jQuery.parseJSON(xhr.responseText); @@ -4355,11 +4352,7 @@ $("#setting_add").validate({ pincode: { maxlength: 15 }, - branch_gstin_number: { - gst: true, - minlength: 15, - maxlength: 15 - } + branch_gstin_number: { branchTaxRegistration: true, maxlength: 100 } }, messages: { store_name: { @@ -4397,10 +4390,7 @@ $("#setting_add").validate({ pincode: { maxlength: "Place should not be more than 15 digits" }, - branch_gstin_number: { - minlength: "Gstr must be Atleast 15 Characters long", - maxlength: "Gstr should not be more than 15 digits" - } + branch_gstin_number: { maxlength: "Use no more than 100 characters" } } }); jQuery.validator.addMethod("setting_phone_number", function (phone_number, element) { @@ -4413,12 +4403,11 @@ jQuery.validator.addMethod("setting_phone_number", function (phone_number, eleme } }, "Enter a valid phone number"); -jQuery.validator.addMethod("gst", function (value, element) { - if (value !== '') { - return /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(value); - } - return true; -}, "Enter a valid GSTIN"); +jQuery.validator.addMethod("branchTaxRegistration", function (value, element) { + if (!value.trim()) return true; + var pattern = $(element).data('tax-regex'); + return !pattern || new RegExp(pattern).test(value.trim()); +}, "Enter a valid tax registration number"); $("#setting_add").submit(function (event) { event.preventDefault(); if ($('#setting_add').valid()) { // checks form for validity @@ -5102,6 +5091,7 @@ $('#setting_country').one('change', function () { var countrySelect = $('#setting_country'); countrySelect.on('select2:select', function (e) { var data = e.params.data; + PosnicPro.settings.applyTaxProfile($('#setting_country').val()); PosnicPro.settings.loadSelectSettingState(data.element.attributes['data-setting-id'].value); PosnicPro.local.set("country_value", data.id); }); diff --git a/frontend/static/script/js/modules/js/suppliers.js b/frontend/static/script/js/modules/js/suppliers.js index 426c67491..247e81c97 100644 --- a/frontend/static/script/js/modules/js/suppliers.js +++ b/frontend/static/script/js/modules/js/suppliers.js @@ -573,8 +573,9 @@ PosnicPro.suppliers = { (response.data.stateJsonArray || []).forEach(function (state) { stateSelect.append(new Option(state, state)); }); - var found = Array.from(stateSelect[0].options).some(function (option) { return option.value === selectedState; }); - stateSelect.val(found ? selectedState : '').trigger('change.select2'); + var match = Array.from(stateSelect[0].options).find(function (option) { return option.value.trim().toLowerCase() === String(selectedState || '').trim().toLowerCase(); }); + if (!match && selectedState) stateSelect.append(new Option(selectedState, selectedState)); + stateSelect.val(match ? match.value : (selectedState || '')).trigger('change.select2'); (PosnicPro.suppliers.supplier_phone || { setCountry: function () {} }).setCountry(response.data.countrySortName); }, function (xhr) { var response = jQuery.parseJSON(xhr.responseText); @@ -618,7 +619,7 @@ PosnicPro.suppliers = { }, gstin_number: { required: true, - gst: true, + suppliersGst: true, minlength: 15, maxlength: 15 } @@ -666,8 +667,8 @@ PosnicPro.suppliers = { } }, "Enter a valid phone number"); - jQuery.validator.addMethod("gst", function (value, element) { - return /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(value); + jQuery.validator.addMethod("suppliersGst", function (value, element) { + return this.optional(element) || /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/.test(value); }, "Enter a valid GSTIN"); $("#supplier_add").submit(function (event) { event.preventDefault(); diff --git a/frontend/static/script/js/modules/js/units.js b/frontend/static/script/js/modules/js/units.js index 93ff61eae..3bb8d4afc 100644 --- a/frontend/static/script/js/modules/js/units.js +++ b/frontend/static/script/js/modules/js/units.js @@ -4,7 +4,7 @@ * here"). Same API the settings tab used (setting/getUnitAll, addUnit upserts * on unit_id, deleteUnit), but the page follows LIST_PAGE_UX_STANDARD.md: * the row opens the unit in the right pane, the pane owns Edit/Delete, and - * the editor is an inline strip IN the pane - no slide-over, no modal. + * short create/edit forms use the shared compact record dialog. */ PosnicPro.units = { _page: 1, @@ -221,32 +221,17 @@ PosnicPro.units = { $('#units_doc_items').html(PosnicPro.i18n.t('lang_could_not_load_the_items_for_this_unit', 'Could not load the items for this unit.')); }); }, - /* The editor is the pane itself: two fields and Save. An empty id is a - new unit; addUnit upserts on unit_id, same as the old settings flow. */ + // Use the same compact dialog as other short record forms. openEditor: function (id) { - var esc = function (t) { return $('').text(t == null ? '' : t).html(); }; var u = id ? PosnicPro.units._find(id) : null; - if (id && !u) { return; } - PosnicPro.listDoc.open({ - key: 'units', - id: id || 'new', - title: u ? 'Edit · ' + u.unit_name : 'New unit', - actions: '', - body: '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '' - + '
' - + ' ' - + '' - + '
' + if (id && !u) return; + $('#unit_page_name').val(u ? u.unit_name : ''); + $('#unit_page_value').val(u ? u.unit_value : ''); + $('#unit_page_error').empty().hide(); + $('#unit_editor').off('submit.units').on('submit.units', function (event) { + event.preventDefault(); PosnicPro.units.save(id || ''); }); + PosnicPro.showAddModal('unit'); setTimeout(function () { $('#unit_page_name').focus(); }, 100); }, save: function (id) { @@ -267,6 +252,7 @@ PosnicPro.units = { }, function (response) { PosnicPro.alert(response.type, response.message); if (response.type !== 'success') { return; } + PosnicPro.HideSideBarModal(); /* The item form's unit dropdown feeds off the same list. */ if (PosnicPro.items && PosnicPro.items.loadSelectUnit) { PosnicPro.items.loadSelectUnit(); } PosnicPro.units.loadList(null, function () { diff --git a/frontend/static/script/js/modules/js/users.js b/frontend/static/script/js/modules/js/users.js index eb979feb0..e23cc6f0b 100644 --- a/frontend/static/script/js/modules/js/users.js +++ b/frontend/static/script/js/modules/js/users.js @@ -1859,6 +1859,15 @@ PosnicPro.users = { }).join(''); $('#users_api').val(apikey); }, + showPermissionError: function () { + var error = $('#checkbox_error').text(PosnicPro.i18n.t('lang_permission_is_required', 'Permission is required')).addClass('error').attr({ role: 'alert', tabindex: '-1' }).show(); + var access = $('#click_collapse').closest('.accordion'); + access.prepend(error); + $('#user_form_upload').trigger('record-reveal', [error[0]]); + access.find('.collapse').addClass('show'); + error[0].focus(); + error[0].scrollIntoView({ block: 'nearest' }); + }, clickNormalForm: function () { $(".user_add").validate({ @@ -1953,12 +1962,12 @@ PosnicPro.users = { return this.optional(element) || /^[a-z\s]+$/i.test(value); }, "Use letters, numbers and spaces only"); if ($('.user_add').valid()) { // checks form for validity - var checked = $(".onoffswitch-checkbox:checked").length; + var checked = $("#user_form_upload .onoffswitch-checkbox[data-id]:checked, #user_form_upload .pos-action-box:checked").length; if (checked > 0) { $("#checkbox_error").text("").removeClass('error'); PosnicPro.users.userForm(); } else { - $("#checkbox_error").text("You must check at least 1 box").addClass('error'); + PosnicPro.users.showPermissionError(); } } }, @@ -1985,12 +1994,12 @@ PosnicPro.users = { $("#users_api").css('border-color', 'rgb(249, 97, 109)').focus(); } else { $("#error_app_name,#error_users_api").hide(); - var checked = $(".onoffswitch-checkbox:checked").length; + var checked = $("#user_form_upload .onoffswitch-checkbox[data-id]:checked, #user_form_upload .pos-action-box:checked").length; if (checked > 0) { $("#checkbox_error").text("").removeClass('error'); PosnicPro.users.userForm(); } else { - $("#checkbox_error").text("You must check at least 1 box").addClass('error'); + PosnicPro.users.showPermissionError(); } } @@ -2488,12 +2497,15 @@ $("#password_close").click(function (element, errorClass) { $("#clicksubmit").click(function () { var usertype = $("input[name='usertype']:checked").val(); if (usertype !== 'api') { - $(".user_add").submit(); PosnicPro.users.clickNormalForm(); } else { PosnicPro.users.clickApiForm(); } }); +$('#user_form_upload').on('submit.recordUser', function (event) { + event.preventDefault(); + $('#clicksubmit').trigger('click'); +}); $('#click_collapse').click(function () { $(this).find('#change_collapse_icon').toggleClass('feather icon-chevron-down feather icon-chevron-up'); }); diff --git a/frontend/static/style/css/record-workspace.css b/frontend/static/style/css/record-workspace.css index 9ac246e2a..0eb607843 100644 --- a/frontend/static/style/css/record-workspace.css +++ b/frontend/static/style/css/record-workspace.css @@ -82,7 +82,7 @@ #category_tile_shapes { display: flex; gap: 12px; flex-wrap: wrap; margin: 12px 0; } #category_tile_shapes .tile-shape { display: inline-flex; flex-direction: column; align-items: center; gap: 8px; min-width: 90px; padding: 12px; border: 2px solid transparent; border-radius: 10px; color: var(--theme-text-primary); background: var(--theme-body-bg); } #category_tile_shapes .tile-shape.is-picked { border-color: var(--theme-primary-color); } -.record-shape { display: block; width: 32px; height: 32px; background: var(--theme-primary-color); } +.record-shape { display: block; width: 32px; height: 32px; background: var(--record-shape-color, var(--theme-primary-color)); } .record-shape-square { border-radius: 0; } .record-shape-rounded { border-radius: 10px; } .record-shape-circle { border-radius: 50%; } @@ -115,9 +115,11 @@ .record-account-status .col-md-6 { flex: none; width: auto; max-width: none; padding: 0; text-align: left !important; } #user_form_upload:has(#apiMethod:checked) .record-staff-profile, #user_form_upload:has(#apiMethod:checked) .profilebox { display: none; } -#units_doc:has(#unit_page_name) { max-width: 680px; margin-inline: auto; } -#units_doc:has(#unit_page_name) .s-doc-body { padding: 16px; } -#units_doc:has(#unit_page_name) .q-sheet { min-height: 0; } +.record-grid > .form-group > label { display: block; } +.record-grid .iti { display: block; width: 100%; } +#supplier_add .record-grid > .form-group:has(#supplier_name) { grid-column: auto; } +#user_form_upload .record-grid > .form-group:has(#users_email) { grid-column: 1 / -1; } +#record-sales-panel-0 { max-width: 1200px; } @media (max-width: 767.98px) { .record-dialog-small.sidebarshow { width: 100vw !important; right: 0; } [dir="rtl"] .record-dialog-small.sidebarshow { left: 0; } diff --git a/tests/record-workspace.test.js b/tests/record-workspace.test.js index 40e215ad0..a133e603c 100644 --- a/tests/record-workspace.test.js +++ b/tests/record-workspace.test.js @@ -6,7 +6,7 @@ const { JSDOM } = require('jsdom'); const root = path.join(__dirname, '..'); const read = p => fs.readFileSync(path.join(root, p), 'utf8'); function fixture() { - const files = ['modals/customer_category_add.html', 'modals/variant_add.html', 'modals/tax.html', 'modals/payment.html', 'modals/denomcash.html', 'modals/expense.html', 'modals/tableorder.html', 'modals/user.html', 'modals/customer.html', 'modals/supplier.html', 'modals/branch.html', 'modals/category_add.html', 'modules/branchEdit.html', 'modules/settings_write.html']; + const files = ['modules/units.html', 'modals/customer_category_add.html', 'modals/variant_add.html', 'modals/tax.html', 'modals/payment.html', 'modals/denomcash.html', 'modals/expense.html', 'modals/tableorder.html', 'modals/user.html', 'modals/customer.html', 'modals/supplier.html', 'modals/branch.html', 'modals/category_add.html', 'modules/branchEdit.html', 'modules/settings_write.html']; const dom = new JSDOM(files.map(p => read('frontend/' + p)).join('\n'), { runScripts: 'outside-only' }); const w = dom.window; w.eval(read('frontend/static/script/js/jquery.min.js')); @@ -122,3 +122,57 @@ test('email validation accepts plus addressing and long domains', () => { for(const value of ['bad email@example.com','owner@@example.com','owner@example']) assert.equal(validate(value),false); dom.window.close(); }); + +test('new branch exposes regional inputs and Units use a compact dialog', () => { + const {w,d,dom}=fixture(); + w.eval(read('frontend/static/script/js/core/record-workspace.js')); w.PosnicPro.recordWorkspace.init(); + for (const id of ['branch_currency','branch_timezone','branch_dateformat','branch_tax_number']) { + const input=d.getElementById(id); assert.equal(input.form.id,'branch_add_form'); + assert.equal(input.closest('.record-form-panel').id,'branch_add_form-section-1'); + } + assert.equal(d.getElementById('branch_tax_number').required,false); + assert.ok(d.getElementById('infobar-settings-sidebar-unit').classList.contains('record-dialog-small')); + dom.window.close(); +}); +test('unrelated checked settings cannot grant user permissions; error opens Access tab', () => { + const {w,d,dom}=fixture(); + w.eval(read('frontend/static/script/js/core/record-workspace.js')); w.PosnicPro.recordWorkspace.init(); + w.HTMLElement.prototype.scrollIntoView=function(){}; + w.jQuery.fn.validate=function(){return this;}; w.jQuery.fn.valid=()=>true; + w.jQuery.validator={addMethod:()=>{}}; + const source=read('frontend/static/script/js/modules/js/users.js'); + const start=source.indexOf(' showPermissionError: function'); + const end=source.indexOf(' clickApiForm:',start); + w.PosnicPro.users=w.eval('({' + source.slice(start,end) + '})'); + let saves=0; w.PosnicPro.users.userForm=()=>saves++; + d.querySelectorAll('#user_form_upload input[type=checkbox]').forEach(n=>n.checked=false); + const outside=d.createElement('input'); outside.type='checkbox';outside.checked=true;outside.className='onoffswitch-checkbox';d.body.append(outside); + w.PosnicPro.users.clickNormalForm(); + assert.equal(saves,0); + const error=d.getElementById('checkbox_error'); + assert.match(error.textContent,/Permission/);assert.equal(error.closest('.record-form-panel').hidden,false); + d.getElementById('sales_read').checked=true;w.PosnicPro.users.clickNormalForm();assert.equal(saves,1); + dom.window.close(); +}); +test('optional branch registration validates using the selected country profile', () => { + const {w,d,dom}=fixture();const methods={};w.jQuery.validator={addMethod:(key,fn)=>methods[key]=fn}; + const source=read('frontend/static/script/js/modules/js/settings.js'); + w.eval(source.match(/jQuery.validator.addMethod\("branchTaxRegistration",[\s\S]*?\}, "Enter a valid tax registration number"\);/)[0]); + const field=d.getElementById('branch_gstin_number'); + const validate=value=>methods.branchTaxRegistration(value,field); + w.jQuery(field).data('tax-regex','^[0-9]{15}$');assert.equal(validate(''),true);assert.equal(validate('bad'),false);assert.equal(validate('123456789012345'),true); + w.jQuery(field).data('tax-regex','');assert.equal(validate('GB-ABC-123'),true); + dom.window.close(); +}); + +test('category swatches update every shape preview and clearing restores theme color', () => { + const {w,d,dom}=fixture(); + const source=read('frontend/static/script/js/modules/js/categories.js'); + const start=source.indexOf(' setCatTileColor:');const end=source.indexOf(' setCatTileShape:',start); + const methods=w.eval('({' + source.slice(start,end) + '})'); + methods.setCatTileColor('#29a1df'); + assert.equal(d.getElementById('category_tile_shapes').style.getPropertyValue('--record-shape-color'),'#29a1df'); + assert.equal(d.getElementById('category_tile_color').value,'#29a1df'); + methods.setCatTileColor('');assert.equal(d.getElementById('category_tile_shapes').style.getPropertyValue('--record-shape-color'),'var(--theme-primary-color)'); + dom.window.close(); +});