feat(hours): install-wide default rate + inline missing-rate CTA

Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
This commit is contained in:
Luca
2026-06-02 11:33:45 +02:00
parent d9251c0850
commit ab6bad17c9
10 changed files with 295 additions and 44 deletions
@@ -0,0 +1,35 @@
/**
* Migration: install-wide default hourly rate.
*
* Background: hour entries resolve a billing rate through
* entry.hourly_rate_minor_override → customer.hourly_rate_minor.
* When a customer had neither set, saving an entry hard-failed with
* HOURLY_RATE_REQUIRED — a confusing save-time error on the hours page.
* This adds an install-wide fallback so a single global rate covers
* every customer who hasn't been given an individual one. The chain
* becomes:
* entry override → customer rate → business_profile default → (CTA).
*
* Stored in minor units (matches customer_accounts.hourly_rate_minor).
* Nullable, default NULL: existing installs keep today's behaviour
* (no implicit rate) until the admin sets one — no surprise rate gets
* applied on upgrade (migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor')) return;
await knex.schema.alterTable('business_profile', (table) => {
table.bigInteger('default_hourly_rate_minor');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor'))) return;
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('default_hourly_rate_minor');
});
};