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
@@ -16,6 +16,7 @@ import {
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input, CountrySelect } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { toast } from 'react-toastify';
export const SettingsBusinessProfilePage: React.FC = () => {
@@ -132,6 +133,30 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
value={profile.vatRateDefault ?? 0}
onChange={(e) => setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
{/* Install-wide fallback hourly rate (migration 113). Stored in
minor units; entered here in major units. Blank = no global
default, so hours-logging then needs a per-customer or
per-entry rate. Comma-tolerant via DecimalInput. */}
<div>
<label className="block text-sm font-medium mb-1">
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => setProfile({
...profile,
defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
})}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string}
/>
<p className="text-xs text-muted-theme mt-1">
{t('businessProfile.field.defaultHourlyRateHint',
'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.',
{ currency: profile.defaultCurrency || 'CHF' })}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}