Skip to content
Merged
2 changes: 2 additions & 0 deletions api/src/models/branch.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,7 @@ class BranchModel {
country: data.country,
country_id: data.country_id,
state: data.state,
city: typeof data.city === 'string' ? data.city.trim().slice(0, 50) : '',
store_address: data.address?.trim(),
store_email: data.email?.trim(),
store_telephone: data.phone?.trim(),
Expand Down Expand Up @@ -1575,6 +1576,7 @@ class BranchModel {
country: data.country,
country_id: data.country_id,
state: data.state,
...(typeof data.city === 'string' ? { city: data.city.trim().slice(0, 50) } : {}),
store_address: data.address?.trim(),
store_email: data.email?.trim(),
store_telephone: data.phone?.trim(),
Expand Down
9 changes: 9 additions & 0 deletions api/tests/unit/models/branch.model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,7 @@ describe('BranchModel.createBranch', () => {
name: 'New Branch',
country: 'IN',
state: 'KL',
city: ' Kochi ',
email: 'test@test.com',
phone: '9999999999',
register: ['Main Register'],
Expand Down Expand Up @@ -978,6 +979,7 @@ describe('BranchModel.createBranch', () => {
const r = await bm.createBranch(data, user);
expect(r.status).toBe(true);
expect(r.data.insertId).toBe(branchId.toString());
expect(mockModel.create.mock.calls[0][0].city).toBe('Kochi');
});

test('returns branchList array on success', async () => {
Expand Down Expand Up @@ -1067,6 +1069,13 @@ describe('BranchModel.updateBranch', () => {
const spy = jest.spyOn(bm, 'updateBranchNameInCollections').mockResolvedValue();
await bm.updateBranch(id.toString(), data, user);
expect(spy).toHaveBeenCalledTimes(1);
expect(mockModel.findOneAndUpdate.mock.calls[0][1].$set).not.toHaveProperty('city');
});

test('saves a supplied city without changing legacy requests that omit it', async () => {
mockModel.findOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) });
await bm.updateBranch(validId(), { ...data, city: ' Kochi ' }, user);
expect(mockModel.findOneAndUpdate.mock.calls[0][1].$set.city).toBe('Kochi');
});

test('an existing register keeps its register_id across edits; new names get new ids', async () => {
Expand Down
22 changes: 22 additions & 0 deletions docs/ORGANIZED_RECORD_FORMS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Organized record forms

The record editor keeps existing input IDs, names, validation, permission controls and save handlers. Customer, supplier, category, user and new-branch editors use a centered 820px dialog on desktop and a full-width sheet on phones. Header and actions remain visible while scrolling. User access and branch setup have separate sections; all sections are revealed before validation so required inputs cannot be skipped. Category shapes are labelled buttons with actual shapes and pressed state.

Sales preferences are grouped into Checkout, Product search, Receipts & sharing and Controls. The existing Core Settings save operation still owns their values. Hardware configuration remains in the app; independent recovery windows remain available through Electron. The floating Desktop menu is removed. Mobile devices retain `settings/devices`; desktop hardware uses `settings/hardware`, eliminating duplicate pane and navigation IDs.

New customers, suppliers and branches start with the current shop's country, state and city. Edits keep the record's saved location. A manually changed country clears the state selection until the user chooses one; an older state-list response cannot overwrite a newer request. A missing shop state is left blank instead of guessing the first state. Branch creation and the legacy branch editor now persist the existing `branches.city` field; requests that omit city preserve the saved value.

## Data and synchronization

- No new collections, permission grants, print modes or payment writes are introduced.
- `customers`, `suppliers` and `categories` retain their existing branch-scoped lanes in POS `api/src/sync/collections.json`, Gateway `apps/sync-gateway/src/collections.js` and agent `apps/sync-agent/sync-config.json`.
- `branches.city` already belongs to the whole branch document. The three manifests already include `branches` as global within the licensed installation, with Gateway access filtering. No new transport or migration is needed. This change supplies the field during creation and legacy editing, without changing its replication.
- Existing IDs, timestamps, conflict resolution, tombstones and uploaded-file paths are unchanged. New form grouping does not change serialization or upload handlers. User access and credentials retain their existing authority and are not newly replicated.
- `city_setting` is a local cache of the active shop response, refreshed alongside country and state; the branch document remains authoritative. Draft forms stay local until the existing Save action is used.
- Expected delivery is unchanged: a saved business record reaches cloud and authorized tills through its existing lane, including queued changes after reconnect. Live multi-device create/update/delete and reconnect delivery were not exercised in this change; matching manifests are not proof of delivery.

## Release

Ship the API city persistence change and frontend assets together. Electron menu organization needs the next desktop installer; the web form layout does not. Preserve independent recovery windows. Do not publish the desktop latest channel before the requested private installer test.

Local verification includes actual-template DOM regression tests for control ownership and values, tab validation, state-selection races, existing phone/permission/settings tests, API branch creation, translation coverage, frontend build, and isolated Electron-rendered layout previews. These previews do not save live shop records.
154 changes: 1 addition & 153 deletions frontend/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -1012,159 +1012,7 @@ <h6><span> <lang class="lang_do_you_want_remove_this_image">Do you want remove t
}
});
</script>
<script>
// Desktop tools quick-access (Electron only). Same actions as the
// File menu and tray - this is the discoverable, in-app door.
(function () {
if (!window.electronAPI || !window.electronAPI.desktop) return;
const style = document.createElement('style');
// Colors ride the app's theme variables (Config -> Appearance)
// with brand-blue fallbacks for pages without a theme.
style.textContent =
'#posnic-desktop-fab{position:fixed;bottom:18px;right:18px;z-index:99990;font-family:inherit}' +
'#posnic-desktop-fab .fab-btn{background:var(--theme-card-bg,#fff);' +
'color:var(--theme-text-primary,#16203a);border:1px solid var(--theme-border-color,#d5dfed);border-radius:8px;' +
'height:46px;min-width:46px;padding:0 14px;display:flex;align-items:center;justify-content:center;' +
'font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.10)}' +
'#posnic-desktop-fab .fab-label{white-space:nowrap;margin-inline-start:8px}' +
'#posnic-desktop-fab button:focus-visible{outline:2px solid var(--theme-primary-color,#506fe4);outline-offset:-3px}' +
'#posnic-desktop-fab .fab-menu{display:none;position:absolute;bottom:48px;right:0;' +
'background:var(--theme-card-bg,#fff);border:1px solid var(--theme-border-color,#e6e6e6);' +
'border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.12);padding:6px;min-width:250px;max-width:calc(100vw - 36px);max-height:calc(100vh - 90px);overflow:auto}' +
'#posnic-desktop-fab.open .fab-menu{display:block}' +
'#posnic-desktop-fab .fab-menu button{display:flex;gap:12px;align-items:center;width:100%;text-align:start;background:none;' +
'border:none;border-radius:5px;padding:10px 12px;font:inherit;font-size:13px;color:var(--theme-text-primary,#16203a);cursor:pointer}' +
'#posnic-desktop-fab .fab-menu button:hover{background:var(--theme-menu-active-bg,#f0f2ff);' +
'color:var(--theme-menu-active-text,inherit)}';
document.head.appendChild(style);

const fab = document.createElement('div');
fab.id = 'posnic-desktop-fab';
fab.innerHTML = '<div class="fab-menu" id="posnic-desktop-menu" role="menu"></div><button type="button" class="fab-btn" aria-haspopup="menu" aria-expanded="false" aria-controls="posnic-desktop-menu" title="Desktop tools" data-t-title="lang_desktop_tools"><i class="feather icon-monitor" aria-hidden="true"></i><span class="fab-label"><lang class="lang_desktop">Desktop</lang></span><span id="posnic-sync-dot"></span></button>';
document.body.appendChild(fab);

const dotStyle = document.createElement('style');
dotStyle.textContent =
'#posnic-sync-dot{display:none;width:9px;height:9px;border-radius:50%;margin-left:8px;' +
'vertical-align:middle;display:inline-block}' +
'#posnic-sync-dot.on{background:#4ade80;box-shadow:0 0 5px #4ade80}' +
'#posnic-sync-dot.off{background:#f87171;box-shadow:0 0 5px #f87171}' +
'#posnic-sync-dot.none{display:none}';
document.head.appendChild(dotStyle);

// Live cloud sync indicator on the button (only when activated)
const syncDot = fab.querySelector('#posnic-sync-dot');
const refreshSyncDot = async () => {
try {
const status = await window.electronAPI.cloud.status();
if (!status.connected) { syncDot.className = 'none'; return; }
syncDot.className = status.sync && status.sync.online ? 'on' : 'off';
} catch (e) { syncDot.className = 'none'; }
};
refreshSyncDot();
setInterval(refreshSyncDot, 30000);

const menu = fab.querySelector('.fab-menu');
const trigger = fab.querySelector('.fab-btn');
const closeMenu = () => { fab.classList.remove('open'); trigger.setAttribute('aria-expanded', 'false'); };
const updateDesktopToolVisibility = () => {
const working = /^#\/(?:kot(?:_v2)?(?:\/|$)|sales\/(?:new(?:\/|$)|[^/]+\/(?:edit|hold)(?:\/|$)))/.test(location.hash);
fab.hidden = working;
if (working) closeMenu();
};
window.addEventListener('hashchange', updateDesktopToolVisibility);
updateDesktopToolVisibility();
const addItem = (label, target, icon, settings) => {
const b = document.createElement('button');
b.type = 'button'; b.setAttribute('role', 'menuitem');
const glyph = document.createElement('i'); glyph.className = 'feather icon-' + icon; glyph.setAttribute('aria-hidden', 'true');
b.appendChild(glyph); b.appendChild(document.createTextNode(label));
b.addEventListener('click', async () => {
closeMenu();
if (settings) hasher.setHash('settings/' + target);
else {
try {
const opened = await window.electronAPI.desktop.open(target);
if (opened === false || (opened && opened.ok === false)) throw new Error('Desktop action unavailable');
} catch (error) {
console.error('[Desktop tools] Could not open ' + target, error);
PosnicPro.alert('error', PosnicPro.i18n.t('lang_request_failed', 'Request failed'));
}
}
});
menu.appendChild(b);
};

window.electronAPI.desktop.capabilities().then((caps) => {
if (PosnicPro.userACL && PosnicPro.userACL.branch && PosnicPro.userACL.branch.write) addItem(PosnicPro.i18n.t('lang_desktop_app', 'App Settings'), 'app', 'settings', true);
addItem(PosnicPro.i18n.t('lang_hardware_manager', 'Hardware Manager'), 'hardware', 'printer');
if (['enable', 'true', true].includes(PosnicPro.local.get('table_options'))) addItem(PosnicPro.i18n.t('lang_desktop_kitchen', 'Kitchen Printing'), 'hardware:kot', 'clipboard');
if (caps.backup) addItem(PosnicPro.i18n.t('lang_desktop_backups', 'Backup & Recovery'), 'backup', 'database');
addItem(PosnicPro.i18n.t('lang_desktop_update_recovery', 'Updates & recovery'), 'update', 'download-cloud');
addItem(PosnicPro.i18n.t('lang_desktop_cloudsync', 'Cloud Sync'), 'cloud', 'cloud');
addItem(PosnicPro.i18n.t('lang_desktop_view_logs', 'View application logs'), 'log', 'file-text');
addItem(PosnicPro.i18n.t('lang_about_posnic', 'About Posnic'), 'about', 'info');
}).catch(() => {});

fab.querySelector('.fab-btn').addEventListener('click', (e) => {
e.stopPropagation();
fab.classList.toggle('open');
trigger.setAttribute('aria-expanded', String(fab.classList.contains('open')));
if (fab.classList.contains('open')) menu.querySelector('button')?.focus();
});
fab.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeMenu(); trigger.focus(); e.preventDefault(); }
const items = Array.from(menu.querySelectorAll('button'));
if (!items.length || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
e.preventDefault(); fab.classList.add('open'); trigger.setAttribute('aria-expanded', 'true');
const at = items.indexOf(document.activeElement);
const next = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : e.key === 'ArrowDown' ? (at + 1) % items.length : (at + items.length - 1) % items.length;
items[next].focus();
});
document.addEventListener('click', (e) => { if (!fab.contains(e.target)) closeMenu(); });
// Chromium can run a microtask between blur and focus. At that
// point activeElement is body, even when focus is moving to a
// different menu item. Hiding the menu then cancels its click.
fab.addEventListener('focusout', (e) => {
if (!fab.contains(e.relatedTarget)) closeMenu();
});

// --- Tray branch switching ---------------------------------
// Mirror the header's branch dropdown into the tray menu, and
// run the app's own switch flow (incl. open-register guard)
// when a branch is picked from the tray.
if (window.electronAPI.tray) {
const harvestBranches = () => {
const items = document.querySelectorAll('#change_branch_list a[data-id]');
if (!items.length) return;
const branches = Array.from(items).map((a) => ({
id: a.getAttribute('data-id'),
name: (a.textContent || '').trim()
})).filter((b) => b.id && b.name);
const currentName = (document.querySelector('.branch-name, #current_branch_name')?.textContent || '').trim() || null;
if (branches.length) {
window.electronAPI.tray.setBranches({ branches, currentName });
}
};
const list = () => document.getElementById('change_branch_list');
const arm = () => {
const el = list();
if (!el) return setTimeout(arm, 2000);
new MutationObserver(harvestBranches).observe(el, { childList: true, subtree: true });
harvestBranches();
};
arm();

window.electronAPI.tray.onSwitchBranch((id) => {
if (window.PosnicPro?.branches?.showChange) {
PosnicPro.branches.showChange(id);
} else {
window.location.hash = '#/branches/' + id + '/change';
}
});
}
})();
</script>
<!-- Desktop configuration is in Manage > Settings. Recovery remains in Electron. -->
<script>
/* Static-asset service worker (web / LAN counters only). The desktop
shell is skipped: its assets are local files owned by the installer
Expand Down
2 changes: 1 addition & 1 deletion frontend/layouts/sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ <h5 class="menu-title">
<!-- Cursor Focus moved under Config (it is configuration);
the #/device_setup page itself is unchanged. -->

<li data-desktop-only><a data-module="branch" data-access="write" href="#/settings/devices" class="manage-settings-entry" id="manage_sec_devices"><i class="feather icon-printer"></i><lang class="lang_desktop_devices">Devices & Printing</lang></a></li>
<li data-desktop-only><a data-module="branch" data-access="write" href="#/settings/hardware" class="manage-settings-entry" id="manage_sec_hardware"><i class="feather icon-printer"></i><lang class="lang_desktop_devices">Devices & Printing</lang></a></li>
<li data-desktop-only><a data-module="branch" data-access="write" href="#/settings/app" class="manage-settings-entry" id="manage_sec_app"><i class="feather icon-monitor"></i><lang class="lang_desktop_app">App Settings</lang></a></li>
<li data-desktop-only><a data-module="branch" data-access="write" href="#/settings/updates" class="manage-settings-entry" id="manage_sec_updates"><i class="feather icon-download-cloud"></i><lang class="lang_desktop_updates">Software Updates</lang></a></li>
<li data-desktop-only><a data-module="branch" data-access="write" href="#/settings/backups" class="manage-settings-entry" id="manage_sec_backups"><i class="feather icon-database"></i><lang class="lang_desktop_backups">Backup & Recovery</lang></a></li>
Expand Down
1 change: 1 addition & 0 deletions frontend/modals/branch.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ <h4 id="exampleModalCenterTitle"><span id="branch_title"><lang class="lang_nav_a
<select class="select2 setState" name="state" data-page="branch_state" id="branch_state" required></select>
</div>
</div>
<div class="form-row"><div class="form-group col-md-12"><label for="branch_city"><lang class="lang_city_title">City</lang></label><input type="text" id="branch_city" name="city" class="form-control border-control branch-trigger" maxlength="50"></div></div>
<div class="form-row">
<div class="form-group col-8">
<label class="form-control-placeholder" for="register"><lang class="lang_addregister_title"> Add Register </lang> </label>
Expand Down
Loading
Loading