From 19bac393b00872a064e04700c1a52211055ef9ed Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 15 Apr 2026 07:56:12 +0200 Subject: [PATCH 1/4] fix(desktop): Windows load hang and renderer helpers (#587) The will-navigate guard compared file URL origins to the literal string file://, but the URL API reports an opaque origin for file pages, so legitimate file navigations were blocked and the window could fail to load reliably on Windows. Import utils/helpers from the renderer entry so esbuild includes window.Helpers in the bundle, restoring formatters and isValidUrl after build:renderer. Documentation: desktop README explains the renderer bundle workflow; Windows desktop troubleshooting covers stuck loading; frontend quality gates table notes the app.js entry and rebuild step. --- desktop/README.md | 11 +++ desktop/src/main/main.js | 22 ++++- desktop/src/renderer/js/app.js | 1 + desktop/src/renderer/js/bundle.js | 95 ++++++++++++++++++- .../DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md | 10 ++ docs/development/FRONTEND_QUALITY_GATES.md | 2 +- 6 files changed, 133 insertions(+), 8 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index fe2c4933..70e42b50 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -67,12 +67,18 @@ For detailed instructions, see [Windows Code Signing Guide](../../docs/WINDOWS_C ## Development +### Renderer bundle + +The UI is bundled from [`src/renderer/js/app.js`](src/renderer/js/app.js) into [`src/renderer/js/bundle.js`](src/renderer/js/bundle.js) with esbuild (`npm run build:renderer`). Anything the app needs at runtime (including [`src/renderer/js/utils/helpers.js`](src/renderer/js/utils/helpers.js), which sets `window.Helpers`) must be imported from `app.js` so it is included in the bundle. After changing renderer source files, run `npm run build:renderer` before packaging or committing an updated `bundle.js`. + ### Run in Development Mode ```bash npm start ``` +(`npm start` runs `build:renderer` first, then launches Electron.) + ### Run with DevTools ```bash @@ -173,6 +179,11 @@ The connection is automatically checked every 30 seconds. - Check that electron-store is working properly - Try clearing settings and re-entering them +**Window stuck on loading, blank content, or unstable navigation (especially Windows):** +- Use the latest release or rebuild from source; older builds could mis-handle `file:` navigation in the main process or ship a renderer bundle without helpers loaded. +- From source, run `npm install` and `npm run build:renderer`, then `npm start` or rebuild the installer. +- See [Desktop build Windows troubleshooting](../../docs/admin/configuration/DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md) for environment-specific build issues. + For more details, see [Desktop Settings Guide](../../docs/DESKTOP_SETTINGS.md). ## Project Structure diff --git a/desktop/src/main/main.js b/desktop/src/main/main.js index b5c72718..e1a3c480 100644 --- a/desktop/src/main/main.js +++ b/desktop/src/main/main.js @@ -211,12 +211,28 @@ ipcMain.on('splash:ready', () => { } }); -// Prevent navigation to external URLs +// Prevent navigation to external URLs (file: uses opaque origin "null", not "file://") app.on('web-contents-created', (event, contents) => { contents.on('will-navigate', (event, navigationUrl) => { - const parsedUrl = new URL(navigationUrl); - if (parsedUrl.origin !== 'file://') { + let parsedUrl; + try { + parsedUrl = new URL(navigationUrl); + } catch { event.preventDefault(); + return; } + const protocol = parsedUrl.protocol; + if ( + protocol === 'file:' || + protocol === 'about:' || + protocol === 'devtools:' + ) { + return; + } + if (protocol === 'http:' || protocol === 'https:') { + event.preventDefault(); + return; + } + event.preventDefault(); }); }); diff --git a/desktop/src/renderer/js/app.js b/desktop/src/renderer/js/app.js index 47dc9ca7..9a1000a0 100644 --- a/desktop/src/renderer/js/app.js +++ b/desktop/src/renderer/js/app.js @@ -1,4 +1,5 @@ // Main application logic +require('./utils/helpers'); const { storeGet, storeSet, storeDelete, storeClear } = window.config || {}; const ApiClient = require('./api/client'); const StorageService = require('./storage/storage'); diff --git a/desktop/src/renderer/js/bundle.js b/desktop/src/renderer/js/bundle.js index 94df84a5..7994da7b 100644 --- a/desktop/src/renderer/js/bundle.js +++ b/desktop/src/renderer/js/bundle.js @@ -23,6 +23,83 @@ }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + // src/renderer/js/utils/helpers.js + var require_helpers = __commonJS({ + "src/renderer/js/utils/helpers.js"(exports, module) { + function formatDuration2(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds % 3600 / 60); + const secs = seconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m ${secs}s`; + } + function formatDurationLong2(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor(seconds % 3600 / 60); + const secs = seconds % 60; + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + function formatDate(date) { + if (typeof date === "string") { + date = new Date(date); + } + return date.toLocaleDateString(); + } + function formatDateTime2(date) { + if (typeof date === "string") { + date = new Date(date); + } + return date.toLocaleString(); + } + function parseISODate(dateString) { + return new Date(dateString); + } + function isValidUrl2(string) { + try { + const url = new URL(string); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (_) { + return false; + } + } + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + if (typeof module !== "undefined" && module.exports) { + module.exports = { + formatDuration: formatDuration2, + formatDurationLong: formatDurationLong2, + formatDate, + formatDateTime: formatDateTime2, + parseISODate, + isValidUrl: isValidUrl2, + debounce + }; + } + if (typeof window !== "undefined") { + window.Helpers = { + formatDuration: formatDuration2, + formatDurationLong: formatDurationLong2, + formatDate, + formatDateTime: formatDateTime2, + parseISODate, + isValidUrl: isValidUrl2, + debounce + }; + } + } + }); + // node_modules/axios/dist/browser/axios.cjs var require_axios = __commonJS({ "node_modules/axios/dist/browser/axios.cjs"(exports, module) { @@ -2751,6 +2828,9 @@ if (reason) data.reason = reason; return await this.client.post(`/api/v1/timesheet-periods/${periodId}/reject`, data); } + async deleteTimesheetPeriod(periodId) { + return await this.client.delete(`/api/v1/timesheet-periods/${periodId}`); + } async getLeaveTypes() { return await this.client.get("/api/v1/time-off/leave-types"); } @@ -2787,6 +2867,9 @@ if (comment) data.comment = comment; return await this.client.post(`/api/v1/time-off/requests/${requestId}/reject`, data); } + async deleteTimeOffRequest(requestId) { + return await this.client.delete(`/api/v1/time-off/requests/${requestId}`); + } }; if (typeof module !== "undefined" && module.exports) { module.exports = ApiClient2; @@ -7730,6 +7813,7 @@ }); // src/renderer/js/app.js + require_helpers(); var { storeGet, storeSet, storeDelete, storeClear } = window.config || {}; var ApiClient = require_client(); var StorageService = require_storage(); @@ -7793,11 +7877,12 @@ const role = String(user.role || "").toLowerCase(); const roleCanApprove = ["admin", "owner", "manager", "approver"].includes(role); state.currentUserProfile = { + id: user.id, is_admin: Boolean(user.is_admin), can_approve: Boolean(user.is_admin) || roleCanApprove }; } catch (_) { - state.currentUserProfile = { is_admin: false, can_approve: false }; + state.currentUserProfile = { id: null, is_admin: false, can_approve: false }; } } function updateConnectionStatus(status) { @@ -8485,8 +8570,9 @@
${String(period.status || "").toLowerCase() === "draft" ? `` : ""} - ${String(period.status || "").toLowerCase() === "submitted" && currentUserProfile.can_approve ? `` : ""} - ${String(period.status || "").toLowerCase() === "submitted" && currentUserProfile.can_approve ? `` : ""} + ${String(period.status || "").toLowerCase() === "submitted" && state.currentUserProfile.can_approve ? `` : ""} + ${String(period.status || "").toLowerCase() === "submitted" && state.currentUserProfile.can_approve ? `` : ""} + ${["draft", "rejected"].includes(String(period.status || "").toLowerCase()) ? `` : ""}
`).join(""); @@ -8534,7 +8620,7 @@ const leaveType = req.leave_type_name || "Leave"; const status = req.status || ""; const pending = String(status).toLowerCase() === "submitted"; - const canReview = pending && currentUserProfile.can_approve; + const canReview = pending && state.currentUserProfile.can_approve; return `
`; diff --git a/docs/admin/configuration/DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md b/docs/admin/configuration/DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md index 03c71181..83050f08 100644 --- a/docs/admin/configuration/DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md +++ b/docs/admin/configuration/DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md @@ -143,6 +143,16 @@ If you're hitting path length limits: - Cleaner, more reliable than npm install - Build script already uses this +## App window stuck on loading or shows blank content + +If the installer or executable starts but the main window never leaves the loading state, shows a blank page, or behaves as if navigation is stuck (often reported on Windows 11): + +1. **Update to the latest build** from the project releases or rebuild from the current `develop` branch. Older builds could mishandle `file:` URL navigation in Electron or ship an incomplete renderer bundle. +2. **Rebuild the renderer** when building from source: from the `desktop` folder run `npm install` then `npm run build:renderer`, then `npm run build:win` (or your usual build command). The packaged app expects an up-to-date `src/renderer/js/bundle.js`. +3. **Confirm the server URL** on the login screen and try again after a full quit and restart. + +If the problem persists after a clean rebuild, open an issue with your app version, Windows build, and any DevTools console output (run `npm run dev` for a local build with DevTools). + ## Additional Resources - [npm Troubleshooting Guide](https://docs.npmjs.com/common-errors) diff --git a/docs/development/FRONTEND_QUALITY_GATES.md b/docs/development/FRONTEND_QUALITY_GATES.md index 10579f27..0dac6acb 100644 --- a/docs/development/FRONTEND_QUALITY_GATES.md +++ b/docs/development/FRONTEND_QUALITY_GATES.md @@ -58,6 +58,6 @@ This document tracks frontend modernization phases and how to run quality checks |------|-----------| | Web base layout | `app/templates/base.html`, `app/static/base-init.js`, `app/static/pwa-enhancements.js` | | Web search/IDs | `app/templates/*/list.html` (unique filter search IDs), `app/static/enhanced-search.js` | -| Desktop renderer | `desktop/src/renderer/js/app.js`, `desktop/src/renderer/js/state.js`, `desktop/src/renderer/js/ui/notifications.js`, `desktop/src/renderer/js/bundle.js` | +| Desktop renderer | `desktop/src/renderer/js/app.js` (esbuild entry; import shared modules such as `utils/helpers.js` from here), `desktop/src/renderer/js/state.js`, `desktop/src/renderer/js/ui/notifications.js`, `desktop/src/renderer/js/bundle.js` (run `npm run build:renderer` after renderer changes) | | Mobile finance | `mobile/lib/presentation/screens/finance_workforce_screen.dart`, `mobile/lib/presentation/providers/finance_workforce_providers.dart` | | Mobile home | `mobile/lib/presentation/screens/home_screen.dart` (IndexedStack for tabs) | From 15ddabdffb5957dde87197999159798537d60742 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 15 Apr 2026 07:57:10 +0200 Subject: [PATCH 2/4] feat(quotes): reorder form rows and improve quote view i18n docs Add Move up / Move down controls and an Order column for quote line items, Costs, and Extra goods on create and edit pages (Issue #584). DOM row order matches POST field order so existing QuoteItem.position handling stays correct. Fix the quote detail Valid until row by using quote.is_expired instead of an undefined Jinja now() (Issue #583). Submit a past valid_until in the web regression test so the view path is exercised. Document translation contributions without Git: add docs/CONTRIBUTING_TRANSLATIONS.md, a Translation improvement issue template, links from CONTRIBUTING.md and TRANSLATION_SYSTEM.md, and a Translations subsection in docs/development/CONTRIBUTING.md. Refresh CHANGELOG [Unreleased] for these items. --- .github/ISSUE_TEMPLATE/translation_fix.yml | 63 ++++++++++++++ CHANGELOG.md | 7 +- CONTRIBUTING.md | 1 + .../quotes/_edit_quote_form_scripts.html | 80 ++++++++++++++++-- app/templates/quotes/create.html | 9 +- app/templates/quotes/edit.html | 29 +++++-- app/templates/quotes/view.html | 2 +- docs/CONTRIBUTING_TRANSLATIONS.md | 82 +++++++++++++++++++ docs/TRANSLATION_SYSTEM.md | 18 ++-- docs/development/CONTRIBUTING.md | 4 + tests/test_routes/test_quotes_web.py | 2 + 11 files changed, 271 insertions(+), 26 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/translation_fix.yml create mode 100644 docs/CONTRIBUTING_TRANSLATIONS.md diff --git a/.github/ISSUE_TEMPLATE/translation_fix.yml b/.github/ISSUE_TEMPLATE/translation_fix.yml new file mode 100644 index 00000000..95a389a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation_fix.yml @@ -0,0 +1,63 @@ +name: Translation improvement +description: Suggest a correction or better wording for interface text in a specific language (no Git required). +title: "[i18n] " +labels: [] +body: + - type: markdown + attributes: + value: | + Thank you for helping improve translations. Before submitting, skim **Rules for translators** in `docs/CONTRIBUTING_TRANSLATIONS.md` in this repository (placeholders, context, what not to change). + + - type: dropdown + id: language + attributes: + label: Language + description: Locale code for the translation you are improving. + options: + - nl (Nederlands) + - de (Deutsch) + - fr (Français) + - it (Italiano) + - fi (Suomi) + - es (Español) + - no (Norsk) + - ar (العربية) + - he (עברית) + - en (English) + validations: + required: true + + - type: input + id: location + attributes: + label: Where did you see this? + description: Page name, menu path, or URL path (e.g. Dashboard, Settings, /login). + validations: + required: true + + - type: textarea + id: current_text + attributes: + label: Current text (as shown in the app) + description: Copy the exact wording from the UI in the language you selected. + validations: + required: true + + - type: textarea + id: suggested_text + attributes: + label: Suggested text + description: Your improved translation. Keep any %(name)s-style placeholders identical to the current text if present. + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Notes (optional) + description: Why this reads better, grammar fix, formal vs informal tone, screenshot description, etc. + + - type: markdown + attributes: + value: | + **Optional:** Attach a screenshot in a follow-up comment if the form does not allow images. diff --git a/CHANGELOG.md b/CHANGELOG.md index e3aff8da..82e3a3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed -- **Quote create returned HTTP 500 after save (#583)** — The quote was persisted, but the redirect to the quote detail page crashed because the view expected `requires_approval` and `can_be_sent`, and templates could set `approval_level`, while the `Quote` model had no matching fields. Added `requires_approval` and `approval_level` columns, a `can_be_sent` property aligned with send rules, wired the create-form checkbox, fixed the approval banner branch to use `approval_status == 'not_required'`, migration `145_add_quotes_requires_approval`, and regression coverage in `tests/test_routes/test_quotes_web.py`. +- **Quote create returned HTTP 500 after save (#583)** — The quote was saved, but the redirect to the quote detail page crashed when **Valid until** was set: the template compared `valid_until` to `now()`, and `now` was never defined in the Jinja context. The expired badge now uses `Quote.is_expired` (same rule, app timezone). Regression coverage in `tests/test_routes/test_quotes_web.py` posts `valid_until` so the view path is exercised. +- **Desktop app navigation guard** — `will-navigate` no longer mis-classifies `file:` loads (opaque `"null"` origin) as external navigation. Allowed in-app protocols include `file:`, `about:`, and `devtools:`; `http:` / `https:` are still blocked from the embedded window. +- **Desktop offline UI (bundle)** — Shared helpers load before dependent modules; timesheet period and time-off request lists expose **Delete** where allowed (with `currentUserProfile.id` for ownership); approve/reject controls read approval state from `state.currentUserProfile`; API client includes `deleteTimesheetPeriod` and `deleteTimeOffRequest`. ### Added -- **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** line tables include **Move up** / **Move down** per row so items can be reordered without deleting and re-entering lines; PDFs and detail views follow the saved order. +- **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** forms include per-row **Move up** / **Move down** controls on **Quote line items**, **Costs**, and **Extra goods** so rows can be reordered without deleting and re-entering data; PDFs and detail views follow the saved order. New translatable UI strings: **Order**, **Move up**, **Move down** (run `pybabel extract` / `update` per [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md)). - **Offline queue replay** — Queued requests now store method, headers, and body in a replay-safe form (serializable for localStorage). POST/PUT requests replayed when back online send the same body and method. Legacy queue items (with `options` only) are still replayed via fallback. - **Inventory API scopes** — New scopes `read:inventory` and `write:inventory` for inventory-only API access. Existing `read:projects` and `write:projects` still grant the same inventory access for backward compatibility. - **Client portal reports: date range and CSV export** — Reports support optional `days` query param (1–365, default 30). Add `?format=csv` to download a CSV of the same report (summary, hours by project, time by date). Export uses the same access control as the reports page. - **Jira webhook verification** — When a webhook secret is configured in the Jira integration (Connection Settings → Webhook Secret), incoming webhooks are verified using HMAC-SHA256 of the request body. Supported headers: `X-Hub-Signature-256`, `X-Atlassian-Webhook-Signature`, `X-Hub-Signature`. Requests with missing or invalid signature are rejected. If no secret is set, behavior is unchanged (all webhooks accepted). ### Changed +- **Documentation (translations)** — Added [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for contributors without Git (issue template, optional spreadsheet or hosted platform, maintainer workflow). Root [CONTRIBUTING.md](CONTRIBUTING.md) links to it; [docs/TRANSLATION_SYSTEM.md](docs/TRANSLATION_SYSTEM.md) defers the enabled locale list to `app/config.py` (`LANGUAGES`) and points translators at the new guide. - **Factur-X / PDF/A-3 invoice PDFs (export and email)** — Download and email attachments use the same embed-and-normalize path. Embedded CII uses Associated File relationship **Data** and MIME **text/xml**. PDF/A-3 normalization embeds sRGB via `app/resources/icc/` (override with `INVOICE_SRGB_ICC_PATH`). Added `app/utils/invoice_pdf_postprocess.py` and tests; [PEPPOL e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md) updated (veraPDF note, pytest command). - **Documentation sync** — CODEBASE_AUDIT.md: marked gaps 2.3–2.7 and 2.9 as fixed; added “Implemented 2026-03-16” summary. CLIENT_FEATURES_IMPLEMENTATION_STATUS: report date range and CSV export noted as implemented. INCOMPLETE_IMPLEMENTATIONS_ANALYSIS: added “Verified 2026-03-16” for webhook verification, issues permissions, search API, offline queue. - **Activity feed API date params** — `/api/activity` now returns 400 with a clear message when `start_date` or `end_date` are invalid (e.g. not ISO 8601). Invalid dates on the web route `/activity` are logged and the filter is skipped (no 500). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fe68d3d..1f6eda56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ Thank you for your interest in contributing to TimeTracker. This page gives you ## How to Contribute - **Report bugs** — Use the [GitHub issue tracker](https://github.com/drytrix/TimeTracker/issues). Include steps to reproduce, expected vs actual behavior, and your environment (OS, deployment method, version). +- **Improve translations (no Git)** — Use the **Translation improvement** issue template, or read [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for spreadsheet / maintainer workflow options. - **Suggest features** — Open a [feature request](https://github.com/drytrix/TimeTracker/issues/new?template=feature_request.md) with a clear description and use case. - **Submit code** — Fork the repo, create a branch, make your changes, add tests, and open a pull request. Follow the [full contributing guidelines](docs/development/CONTRIBUTING.md) for setup, coding standards, and PR process. diff --git a/app/templates/quotes/_edit_quote_form_scripts.html b/app/templates/quotes/_edit_quote_form_scripts.html index a0cfd248..53e64d8d 100644 --- a/app/templates/quotes/_edit_quote_form_scripts.html +++ b/app/templates/quotes/_edit_quote_form_scripts.html @@ -10,6 +10,54 @@ document.addEventListener('DOMContentLoaded', function() { const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }}; const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }}; + function reorderButtonsHtml() { + return ( + '
' + + '' + + '
' + ); + } + + function refreshReorderStates(parent, rowSelector) { + if (!parent) return; + const rows = parent.querySelectorAll(rowSelector); + rows.forEach(function(r, i) { + const up = r.querySelector('.quote-line-move-up'); + const down = r.querySelector('.quote-line-move-down'); + if (up) up.disabled = i === 0; + if (down) down.disabled = i === rows.length - 1; + }); + } + + function moveRowInSection(row, delta) { + const parent = row.parentElement; + if (!parent) return; + var sel = null; + if (row.classList.contains('quote-item-row')) sel = '.quote-item-row'; + else if (row.classList.contains('quote-expense-row')) sel = '.quote-expense-row'; + else if (row.classList.contains('quote-good-row')) sel = '.quote-good-row'; + else return; + const rows = Array.from(parent.querySelectorAll(sel)); + const idx = rows.indexOf(row); + if (idx < 0) return; + const newIdx = idx + delta; + if (newIdx < 0 || newIdx >= rows.length) return; + const ref = rows[newIdx]; + if (delta < 0) parent.insertBefore(row, ref); + else parent.insertBefore(row, ref.nextSibling); + refreshReorderStates(parent, sel); + calculateTotals(); + } + + function wireReorderButtons(row) { + row.querySelector('.quote-line-move-up')?.addEventListener('click', function() { + moveRowInSection(row, -1); + }); + row.querySelector('.quote-line-move-down')?.addEventListener('click', function() { + moveRowInSection(row, 1); + }); + } + function stockOptionsHtml(selectedId) { let h = ''; if (stockItems && Array.isArray(stockItems)) { @@ -40,7 +88,7 @@ document.addEventListener('DOMContentLoaded', function() { if (!stockCols || !descWrap) return; if (stockMode) { stockCols.classList.remove('hidden'); - descWrap.classList.remove('md:col-span-6'); + descWrap.classList.remove('md:col-span-5'); descWrap.classList.add('md:col-span-2'); } else { stockCols.classList.add('hidden'); @@ -53,7 +101,7 @@ document.addEventListener('DOMContentLoaded', function() { if (ss) ss.value = ''; if (ws) ws.value = ''; descWrap.classList.remove('md:col-span-2'); - descWrap.classList.add('md:col-span-6'); + descWrap.classList.add('md:col-span-5'); } } @@ -94,12 +142,15 @@ document.addEventListener('DOMContentLoaded', function() { } row.querySelector('.remove-item')?.addEventListener('click', function() { row.remove(); + if (itemsContainer) refreshReorderStates(itemsContainer, '.quote-item-row'); calculateTotals(); }); + wireReorderButtons(row); row.querySelectorAll('[data-calc-trigger]').forEach(function(el) { el.addEventListener('input', calculateTotals); }); applyItemRowLayout(row); + if (itemsContainer) refreshReorderStates(itemsContainer, '.quote-item-row'); } function addItemRow() { @@ -109,14 +160,15 @@ document.addEventListener('DOMContentLoaded', function() { '' + '' + '' + + reorderButtonsHtml() + '
' + '
' + - '