mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
@@ -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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Manual Crowdin sync: uploads English source .po, downloads translations, opens a PR.
|
||||
# Prerequisites: repo secrets CROWDIN_PROJECT_ID and CROWDIN_PERSONAL_TOKEN (see docs/CONTRIBUTING_TRANSLATIONS.md).
|
||||
name: Crowdin sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Crowdin push and pull
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
# Set to true once to seed Crowdin with existing translations/…/messages.po, then set back to false.
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: i18n/crowdin
|
||||
create_pull_request: true
|
||||
pull_request_title: "chore(i18n): Crowdin translations"
|
||||
pull_request_body: "Automated sync from Crowdin. Review placeholders and `no` vs `nb` paths before merge."
|
||||
commit_message: "chore(i18n): sync Crowdin translations"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
+6
-2
@@ -8,16 +8,20 @@ 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).
|
||||
- **Crowdin integration (maintainers)** — Root [`crowdin.yml`](crowdin.yml) maps `translations/en/LC_MESSAGES/messages.po` to per-locale `messages.po` paths (with `nb` → `no` for Norwegian). Manual [`.github/workflows/crowdin-sync.yml`](.github/workflows/crowdin-sync.yml) uploads sources and downloads translations when `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` are set. [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) includes a Crowdin setup section; [docs/TRANSLATION_SYSTEM.md](docs/TRANSLATION_SYSTEM.md) and contributor docs cross-link it.
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -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, and optional Crowdin setup ([`crowdin.yml`](crowdin.yml), **Actions → Crowdin sync**).
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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 (
|
||||
'<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">' +
|
||||
'<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _("Move up") }}" aria-label="{{ _("Move up") }}"><i class="fas fa-chevron-up text-xs"></i></button>' +
|
||||
'<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _("Move down") }}" aria-label="{{ _("Move down") }}"><i class="fas fa-chevron-down text-xs"></i></button></div>'
|
||||
);
|
||||
}
|
||||
|
||||
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 = '<option value="">{{ _("None") }}</option>';
|
||||
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() {
|
||||
'<input type="hidden" name="item_id[]" value="">' +
|
||||
'<input type="hidden" name="item_stock_item_id[]" value="">' +
|
||||
'<input type="hidden" name="item_warehouse_id[]" value="">' +
|
||||
reorderButtonsHtml() +
|
||||
'<div class="md:col-span-2 min-w-0">' +
|
||||
'<select name="item_line_source[]" class="form-input item-line-source text-sm">' +
|
||||
'<option value="manual" selected>{{ _("Manual entry") }}</option>' +
|
||||
'<option value="stock">{{ _("From stock") }}</option></select></div>' +
|
||||
'<div class="item-stock-cols md:col-span-4 min-w-0 hidden grid grid-cols-1 md:grid-cols-2 gap-3">' +
|
||||
'<div class="item-stock-cols md:col-span-3 min-w-0 hidden grid grid-cols-1 md:grid-cols-2 gap-3">' +
|
||||
'<select class="form-input item-stock-select text-sm">' + stockOptionsHtml() + '</select>' +
|
||||
'<select class="form-input item-warehouse-select text-sm">' + warehouseOptionsHtml() + '</select></div>' +
|
||||
'<div class="item-desc-wrap md:col-span-6 min-w-0">' +
|
||||
'<div class="item-desc-wrap md:col-span-5 min-w-0">' +
|
||||
'<input type="text" name="item_description[]" class="w-full form-input item-description" placeholder="{{ _("Item description") }}" data-calc-trigger></div>' +
|
||||
'<input type="number" name="item_quantity[]" value="1" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>' +
|
||||
'<input type="text" name="item_unit[]" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="{{ _("Unit") }}">' +
|
||||
@@ -142,8 +194,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition';
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="qe_id[]" value="">' +
|
||||
reorderButtonsHtml() +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _("Title") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><select name="qe_category[]" class="form-input">' + qeCategoryOptions + '</select></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="number" name="qe_amount[]" class="form-input qe-amount" step="0.01" min="0" placeholder="0" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="date" name="qe_date[]" class="form-input user-date-input text-sm"></div>' +
|
||||
@@ -152,11 +205,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
expensesContainer.appendChild(row);
|
||||
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
|
||||
calculateTotals();
|
||||
});
|
||||
wireReorderButtons(row);
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
@@ -172,7 +228,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition';
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="qg_id[]" value="">' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _("Name") }}" data-calc-trigger></div>' +
|
||||
reorderButtonsHtml() +
|
||||
'<div class="md:col-span-1 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _("Name") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><select name="qg_category[]" class="form-input">' + qgCategoryOptions + '</select></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="number" name="qg_quantity[]" class="form-input qg-quantity" value="1" step="0.01" min="0" data-calc-trigger></div>' +
|
||||
@@ -183,11 +240,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
goodsContainer.appendChild(row);
|
||||
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
|
||||
calculateTotals();
|
||||
});
|
||||
wireReorderButtons(row);
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
@@ -248,8 +308,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.quote-expense-row').forEach(function(row) {
|
||||
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
|
||||
calculateTotals();
|
||||
});
|
||||
wireReorderButtons(row);
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
@@ -257,13 +319,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.quote-good-row').forEach(function(row) {
|
||||
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
|
||||
calculateTotals();
|
||||
});
|
||||
wireReorderButtons(row);
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
});
|
||||
|
||||
if (itemsContainer) refreshReorderStates(itemsContainer, '.quote-item-row');
|
||||
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
|
||||
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
|
||||
|
||||
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
|
||||
calculateTotals();
|
||||
});
|
||||
|
||||
@@ -58,8 +58,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Line type') }}</div>
|
||||
<div class="md:col-span-4">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-3">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Qty') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Unit') }}</div>
|
||||
@@ -89,8 +90,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Title') }}</div>
|
||||
<div class="md:col-span-3">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Amount') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Date') }}</div>
|
||||
@@ -119,7 +121,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Name') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Name') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Qty') }}</div>
|
||||
|
||||
@@ -63,8 +63,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Line type') }}</div>
|
||||
<div class="md:col-span-4 item-stock-header">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-3 item-stock-header">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Qty') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Unit') }}</div>
|
||||
@@ -77,13 +78,17 @@
|
||||
<input type="hidden" name="item_id[]" value="{{ item.id }}">
|
||||
<input type="hidden" name="item_stock_item_id[]" value="{{ item.stock_item_id if item.stock_item_id else '' }}">
|
||||
<input type="hidden" name="item_warehouse_id[]" value="{{ item.warehouse_id if item.warehouse_id else '' }}">
|
||||
<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">
|
||||
<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up text-xs"></i></button>
|
||||
<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down text-xs"></i></button>
|
||||
</div>
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<select name="item_line_source[]" class="form-input item-line-source text-sm" title="{{ _('Line type') }}">
|
||||
<option value="manual" {% if not item.stock_item_id %}selected{% endif %}>{{ _('Manual entry') }}</option>
|
||||
<option value="stock" {% if item.stock_item_id %}selected{% endif %}>{{ _('From stock') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="item-stock-cols md:col-span-4 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-3 {% if not item.stock_item_id %}hidden{% endif %}">
|
||||
<div class="item-stock-cols md:col-span-3 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-3 {% if not item.stock_item_id %}hidden{% endif %}">
|
||||
<select class="form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for stock_item in stock_items %}
|
||||
@@ -97,7 +102,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="item-desc-wrap md:col-span-2 min-w-0 {% if not item.stock_item_id %}md:col-span-6{% endif %}">
|
||||
<div class="item-desc-wrap min-w-0 {% if item.stock_item_id %}md:col-span-2{% else %}md:col-span-5{% endif %}">
|
||||
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="w-full form-input item-description" data-calc-trigger>
|
||||
</div>
|
||||
<input type="number" name="item_quantity[]" placeholder="{{ _('Qty') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>
|
||||
@@ -133,8 +138,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Title') }}</div>
|
||||
<div class="md:col-span-3">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Amount') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Date') }}</div>
|
||||
@@ -144,8 +150,12 @@
|
||||
{% for item in quote.items if (item.line_kind or 'item') == 'expense' %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition">
|
||||
<input type="hidden" name="qe_id[]" value="{{ item.id }}">
|
||||
<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">
|
||||
<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up text-xs"></i></button>
|
||||
<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down text-xs"></i></button>
|
||||
</div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _('Title') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<select name="qe_category[]" class="form-input">
|
||||
<option value="travel" {% if item.category == 'travel' %}selected{% endif %}>{{ _('Travel') }}</option>
|
||||
@@ -185,7 +195,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Name') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Name') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Qty') }}</div>
|
||||
@@ -197,7 +208,11 @@
|
||||
{% for item in quote.items if (item.line_kind or 'item') == 'good' %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition">
|
||||
<input type="hidden" name="qg_id[]" value="{{ item.id }}">
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _('Name') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
|
||||
<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">
|
||||
<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up text-xs"></i></button>
|
||||
<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down text-xs"></i></button>
|
||||
</div>
|
||||
<div class="md:col-span-1 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _('Name') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<select name="qg_category[]" class="form-input">
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
<dt class="font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Valid Until') }}</dt>
|
||||
<dd class="text-gray-600 dark:text-gray-400">
|
||||
{{ quote.valid_until|format_date if quote.valid_until else '' }}
|
||||
{% if quote.valid_until and quote.valid_until < now() %}
|
||||
{% if quote.is_expired %}
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ _('Expired') }}</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Crowdin CLI / GitHub Action configuration.
|
||||
# See docs/CONTRIBUTING_TRANSLATIONS.md → Crowdin setup.
|
||||
#
|
||||
# Secrets (never commit real values):
|
||||
# CROWDIN_PROJECT_ID – numeric project ID from Crowdin → Project Settings → Details
|
||||
# CROWDIN_PERSONAL_TOKEN – Account Settings → API → New token (scope: Project; Manager on this project)
|
||||
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
base_path: .
|
||||
base_url: https://api.crowdin.com
|
||||
|
||||
preserve_hierarchy: true
|
||||
|
||||
files:
|
||||
- source: /translations/en/LC_MESSAGES/messages.po
|
||||
translation: /translations/%two_letters_code%/LC_MESSAGES/%original_file_name%
|
||||
# Minor edits to English source strings keep existing translations as unapproved instead of wiping them
|
||||
update_option: update_as_unapproved
|
||||
# Crowdin often uses "nb" for Norwegian Bokmål; this app uses locale folder "no" (see app/config.py).
|
||||
languages_mapping:
|
||||
two_letters_code:
|
||||
"nb": "no"
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
<div class="entry-actions">
|
||||
${String(period.status || "").toLowerCase() === "draft" ? `<button class="btn btn-sm btn-primary" onclick="submitTimesheetPeriodAction(${period.id})">Submit</button>` : ""}
|
||||
${String(period.status || "").toLowerCase() === "submitted" && currentUserProfile.can_approve ? `<button class="btn btn-sm btn-primary" onclick="reviewTimesheetPeriodAction(${period.id}, true)">Approve</button>` : ""}
|
||||
${String(period.status || "").toLowerCase() === "submitted" && currentUserProfile.can_approve ? `<button class="btn btn-sm btn-danger" onclick="reviewTimesheetPeriodAction(${period.id}, false)">Reject</button>` : ""}
|
||||
${String(period.status || "").toLowerCase() === "submitted" && state.currentUserProfile.can_approve ? `<button class="btn btn-sm btn-primary" onclick="reviewTimesheetPeriodAction(${period.id}, true)">Approve</button>` : ""}
|
||||
${String(period.status || "").toLowerCase() === "submitted" && state.currentUserProfile.can_approve ? `<button class="btn btn-sm btn-danger" onclick="reviewTimesheetPeriodAction(${period.id}, false)">Reject</button>` : ""}
|
||||
${["draft", "rejected"].includes(String(period.status || "").toLowerCase()) ? `<button class="btn btn-sm btn-danger" onclick="deleteTimesheetPeriodAction(${period.id})">Delete</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).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 `
|
||||
<div class="entry-item">
|
||||
<div class="entry-info">
|
||||
@@ -8545,6 +8631,7 @@
|
||||
<div class="entry-time">${status}</div>
|
||||
${canReview ? `<button class="btn btn-sm btn-primary" onclick="reviewTimeOffRequestAction(${req.id}, true)">Approve</button>` : ""}
|
||||
${canReview ? `<button class="btn btn-sm btn-danger" onclick="reviewTimeOffRequestAction(${req.id}, false)">Reject</button>` : ""}
|
||||
${["draft", "submitted", "cancelled"].includes(String(status).toLowerCase()) && (req.user_id === state.currentUserProfile.id || state.currentUserProfile.can_approve) ? `<button class="btn btn-sm btn-danger" onclick="deleteTimeOffRequestAction(${req.id})">Delete</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Contributing translations (no Git required)
|
||||
|
||||
This project uses **GNU gettext** `.po` files under `translations/<locale>/LC_MESSAGES/messages.po`, compiled at app startup (see [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md)). You do **not** need Git or developer tools to suggest fixes.
|
||||
|
||||
## How we accept help (channels)
|
||||
|
||||
Maintainers should pick what fits workload and community size. The **default** for this repository is **A**; scale up to **B** or **C** when needed.
|
||||
|
||||
| Channel | Best for | Git? |
|
||||
|--------|-----------|------|
|
||||
| **A. GitHub issue (recommended default)** | Wrong/missing wording, one string or a small batch | No — use the “Translation improvement” template when creating an issue |
|
||||
| **B. Spreadsheet or form** | Many rows at once, non-GitHub users | No — maintainer copies suggestions into `.po` |
|
||||
| **C. Hosted translation platform** | Ongoing community, many languages, history and glossaries | No for translators; maintainer connects repo or uploads `.po` |
|
||||
|
||||
### A. GitHub issue (primary)
|
||||
|
||||
1. Open a new issue and choose **Translation improvement**.
|
||||
2. Fill in language, where you saw the text, current UI text, and your suggested wording.
|
||||
3. A maintainer updates the correct `messages.po` and merges the change.
|
||||
|
||||
No repository access is required.
|
||||
|
||||
### B. Spreadsheet or form (optional)
|
||||
|
||||
Use when contributors cannot or will not use GitHub:
|
||||
|
||||
1. Maintainer shares a table with columns such as: **Language code**, **Screen or page**, **Text as shown now**, **Should be (your suggestion)**, **Notes**.
|
||||
2. Contributors only edit the suggestion column.
|
||||
3. Maintainer applies changes to the `.po` files and validates placeholders (see below).
|
||||
|
||||
### C. Hosted platform (optional, higher volume)
|
||||
|
||||
Examples: [Weblate](https://weblate.org/) (open source, can be self-hosted), [Crowdin](https://crowdin.com/), [POEditor](https://poeditor.com/), [Transifex](https://www.transifex.com/). Translators work in the browser; integration or export/import keeps `.po` in sync with the codebase. Setup is maintainer-owned.
|
||||
|
||||
#### Crowdin setup (maintainers)
|
||||
|
||||
This repo includes a root [`crowdin.yml`](../crowdin.yml) that maps **source** `translations/en/LC_MESSAGES/messages.po` to **translations** under `translations/<locale>/LC_MESSAGES/messages.po`, with **`nb` → `no`** so Norwegian matches `app/config.py` (`no`, not `nb`). You may still have a legacy `translations/nb/` tree locally; prefer **`no`** in Crowdin and in config so you do not maintain two Norwegian copies.
|
||||
|
||||
1. **Create a Crowdin account and project** at [crowdin.com](https://crowdin.com/) → **Create project**.
|
||||
2. **Source language:** English. Treat the resource as **Gettext PO** (`.po`).
|
||||
3. **Target languages:** Add every locale you ship: `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he` (match `LANGUAGES` in `app/config.py`). For Norwegian, add Norwegian (Bokmål) in Crowdin; the `crowdin.yml` mapping writes files into `translations/no/`.
|
||||
4. **Sync with this repository (pick one):**
|
||||
- **GitHub Action:** In the GitHub repo, add Actions secrets `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` (Crowdin project **Details** shows the numeric project ID; **Account Settings → API** creates the token with project access, typically Manager). Run **Crowdin sync** from the **Actions** tab → **Run workflow**. For a **one-time** import of existing `.po` files into Crowdin’s translation memory, temporarily set `upload_translations: true` in [.github/workflows/crowdin-sync.yml](../.github/workflows/crowdin-sync.yml), run it once, then set it back to `false`.
|
||||
- **Crowdin’s GitHub integration:** Crowdin → **Integrations → GitHub** → connect the repo and branch; point it at the same `crowdin.yml` so Crowdin can open PRs when translations are updated.
|
||||
- **Crowdin CLI:** Install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/), export the same env vars, run `crowdin upload sources` (and optionally `crowdin upload translations` once) from the repository root.
|
||||
5. **When developers add or change `_()` strings:** Run `pybabel extract` / `pybabel update` locally (see [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md)), commit if you version those files, then upload sources to Crowdin again.
|
||||
6. **Landing translations:** Approve in Crowdin if you use review, then download (workflow or integration PR), merge, and run the app so `.mo` files rebuild.
|
||||
|
||||
Translators only need a Crowdin account; they do not use git.
|
||||
|
||||
### Other options (reference)
|
||||
|
||||
- **Poedit:** Maintainer can zip `translations/<lang>/LC_MESSAGES/messages.po` for a trusted translator; they edit in [Poedit](https://poedit.net/) and send the file back. Avoid two people editing the same locale in parallel without coordination.
|
||||
- **GitHub web editor on `.po` files:** Possible for experts only; easy to break quoting or plural blocks.
|
||||
|
||||
## Rules for translators
|
||||
|
||||
Follow these so your suggestion can be applied without breaking the app:
|
||||
|
||||
1. **Do not change English source keys.** In `.po` files those are `msgid` lines. In an issue or spreadsheet you describe what you see; maintainers map it to the file. Never invent a new English “key” string.
|
||||
2. **Preserve placeholders exactly.** If the UI shows `Hello, %(username)s` or similar, your translation must include the same placeholders (same names, same `%(name)s`-style segments). Same for `%s`, `%d`, or other format tokens.
|
||||
3. **Plurals:** Some strings have one vs many forms. If you are unsure, describe the case in **Notes** and a maintainer will set `msgstr[0]` / `msgstr[1]` correctly in the `.po` file.
|
||||
4. **Context matters.** Say which **page**, **button**, or **dialog** the text appears on, and attach a **screenshot** if possible. One English phrase can appear in multiple places with different meanings.
|
||||
5. **Length and tone:** Short labels (buttons, nav) should stay compact. Full sentences can be more natural in your language than literal word-for-word English.
|
||||
|
||||
**Supported locale codes** (see `app/config.py` `LANGUAGES`): `en`, `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he`.
|
||||
|
||||
## Maintainer workflow
|
||||
|
||||
Designate at least one person responsible for translation intake (issues, spreadsheet, or platform export).
|
||||
|
||||
### Applying contributor suggestions
|
||||
|
||||
1. Identify the locale file: `translations/<locale>/LC_MESSAGES/messages.po`.
|
||||
2. Find the entry (by `msgid` / English source or grep for the current `msgstr`).
|
||||
3. Update `msgstr` (and plural `msgstr[n]` if needed). Remove `#, fuzzy` if you are sure the translation is correct (fuzzy entries may be ignored at compile time depending on setup).
|
||||
4. Restart the app or trigger your usual deploy so `.mo` is regenerated (see [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md) — compilation runs on startup via `app/utils/i18n.py`).
|
||||
|
||||
### After new UI strings ship in code
|
||||
|
||||
When developers add or change translatable strings:
|
||||
|
||||
```bash
|
||||
pybabel extract -F babel.cfg -o messages.pot .
|
||||
pybabel update -i messages.pot -d translations
|
||||
```
|
||||
|
||||
Then fill new empty entries in each `messages.po`, run the app, and smoke-test critical screens in a few locales.
|
||||
|
||||
### Verification checklist
|
||||
|
||||
- [ ] Placeholders in `msgstr` match the `msgid` / source string.
|
||||
- [ ] `.po` file is valid UTF-8 and parses (Poedit or `msgfmt --check`).
|
||||
- [ ] UI checked in the target language for overflow or clipping on small screens (especially for short buttons).
|
||||
|
||||
## See also
|
||||
|
||||
- Technical overview: [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
TimeTracker includes a comprehensive internationalization (i18n) system powered by Flask-Babel. The application supports 6 languages out of the box:
|
||||
TimeTracker includes a comprehensive internationalization (i18n) system powered by Flask-Babel. Enabled locales are defined in `app/config.py` (`LANGUAGES`), for example:
|
||||
|
||||
- **English** (en) - Default
|
||||
- **Dutch** (nl - Nederlands)
|
||||
@@ -10,6 +10,7 @@ TimeTracker includes a comprehensive internationalization (i18n) system powered
|
||||
- **French** (fr - Français)
|
||||
- **Italian** (it - Italiano)
|
||||
- **Finnish** (fi - Suomi)
|
||||
- **Spanish** (es), **Norwegian** (no), **Arabic** (ar), **Hebrew** (he), and others as configured
|
||||
|
||||
## User Experience
|
||||
|
||||
@@ -38,7 +39,7 @@ Language preference is persisted:
|
||||
|
||||
### Translation Files
|
||||
|
||||
Translation files are located in `translations/` directory:
|
||||
Translation files are located in `translations/` directory (see `app/config.py` for the full list of enabled locales; additional `.po` files may exist for locales not yet wired in config).
|
||||
|
||||
```
|
||||
translations/
|
||||
@@ -47,7 +48,9 @@ translations/
|
||||
├── de/LC_MESSAGES/messages.po # German
|
||||
├── fr/LC_MESSAGES/messages.po # French
|
||||
├── it/LC_MESSAGES/messages.po # Italian
|
||||
└── fi/LC_MESSAGES/messages.po # Finnish
|
||||
├── fi/LC_MESSAGES/messages.po # Finnish
|
||||
├── es/LC_MESSAGES/messages.po # Spanish
|
||||
└── ... # Other locales as configured
|
||||
```
|
||||
|
||||
### Configuration
|
||||
@@ -252,7 +255,7 @@ Potential improvements:
|
||||
|
||||
1. Add more languages (Spanish, Portuguese, Japanese, etc.)
|
||||
2. Right-to-left (RTL) language support (Arabic, Hebrew)
|
||||
3. User-contributed translations via Crowdin or similar
|
||||
3. User-contributed translations via [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issues, spreadsheet, or a hosted platform such as Weblate/Crowdin)
|
||||
4. Automatic language detection improvement
|
||||
5. Translation coverage reporting
|
||||
|
||||
@@ -261,13 +264,14 @@ Potential improvements:
|
||||
For questions or issues with translations:
|
||||
|
||||
1. Check this documentation
|
||||
2. Review `app/__init__.py` locale selector
|
||||
3. Inspect browser network requests to `/i18n/set-language`
|
||||
4. Check application logs for translation compilation errors
|
||||
2. **Contributors without Git:** see [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issue template, spreadsheet option, maintainer workflow, and optional [Crowdin](https://crowdin.com/) using root [`crowdin.yml`](../crowdin.yml) and the **Crowdin sync** GitHub Action)
|
||||
3. Review `app/__init__.py` locale selector
|
||||
4. Inspect browser network requests to `/i18n/set-language`
|
||||
5. Check application logs for translation compilation errors
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-07
|
||||
**Last Updated**: 2026-04-15
|
||||
**Flask-Babel Version**: 4.0.0
|
||||
**Babel Version**: 2.14.0
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,6 +50,10 @@ This project and everyone participating in it is governed by our [Code of Conduc
|
||||
- Push to the branch (`git push origin feature/amazing-feature`)
|
||||
- Open a Pull Request
|
||||
|
||||
### Translations (no Git required)
|
||||
|
||||
Contributors who only want to fix wording can use the **Translation improvement** GitHub issue template or follow [CONTRIBUTING_TRANSLATIONS.md](../CONTRIBUTING_TRANSLATIONS.md) (spreadsheet option, maintainer workflow, optional Crowdin using [`crowdin.yml`](../../crowdin.yml) and the **Crowdin sync** workflow). Developers adding new `_('...')` strings should run `pybabel extract` / `update` as described there.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='5.3.0',
|
||||
version='5.3.1',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
package_data={
|
||||
|
||||
@@ -14,6 +14,8 @@ def test_create_quote_redirect_then_view_returns_200(admin_authenticated_client,
|
||||
"title": "Regression Quote 583",
|
||||
"tax_rate": "0",
|
||||
"currency_code": "EUR",
|
||||
# Triggers Valid until block + expired badge on view (issue #583 used undefined now()).
|
||||
"valid_until": "2020-01-01",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user