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:
@@ -15,8 +15,9 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, LocalizedDateInput } from '../common';
|
||||
import { DecimalInput } from '../common/DecimalInput';
|
||||
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
||||
@@ -91,6 +92,22 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
|
||||
// Install-wide fallback rate (migration 113). Last link in the rate
|
||||
// chain after the per-entry override and the per-customer default.
|
||||
const installDefaultRateMinor = profileSnapshot?.profile?.defaultHourlyRateMinor ?? null;
|
||||
// The rate that applies to a NEW entry when no per-entry override is
|
||||
// typed: customer rate, else the install default. null = neither set,
|
||||
// so a save would fail unless the admin enters an override.
|
||||
const effectiveDefaultRateMinor = customerHourlyRateMinor ?? installDefaultRateMinor;
|
||||
// True when there's genuinely no rate to bill at — drives the inline
|
||||
// CTA + disables the save button. An override typed in the form lifts
|
||||
// this (handled below where the button is rendered).
|
||||
const noRateConfigured = effectiveDefaultRateMinor == null;
|
||||
const overrideTyped = (() => {
|
||||
if (!rateOverride.trim()) return false;
|
||||
const n = parseLocaleDecimal(rateOverride);
|
||||
return Number.isFinite(n) && n >= 0;
|
||||
})();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.createHourEntry(customerId, {
|
||||
@@ -114,7 +131,17 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
toast.success(t('customers.hours.toast.created', 'Entry logged'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.error || err?.message || 'Failed to log entry';
|
||||
// The save-time "no rate" failure is translated here off the
|
||||
// backend error code (the raw message is English-only). The inline
|
||||
// guard below normally prevents this, but a race (rate cleared in
|
||||
// another tab) can still surface it.
|
||||
if (err?.response?.data?.code === 'HOURLY_RATE_REQUIRED') {
|
||||
toast.error(t('customers.hours.error.noRate',
|
||||
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
|
||||
return;
|
||||
}
|
||||
const msg = err?.response?.data?.error || err?.message
|
||||
|| t('customers.hours.error.createFailed', 'Failed to log entry');
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
@@ -153,11 +180,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
for (const e of entries) {
|
||||
if (e.status !== 'unbilled') continue;
|
||||
count += 1;
|
||||
const rateMinor = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
|
||||
const rateMinor = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
|
||||
minor += rateMinor * e.durationMinutes / 60;
|
||||
}
|
||||
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
|
||||
}, [entries, customerHourlyRateMinor]);
|
||||
}, [entries, effectiveDefaultRateMinor]);
|
||||
const isMonthly = billingCadence === 'monthly';
|
||||
|
||||
// Local lockout check — mirrors customerHoursService.isEntryLocked
|
||||
@@ -184,33 +211,72 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')}
|
||||
</p>
|
||||
|
||||
{/* Default rate — hidden in compact mode (history-only on the
|
||||
customer detail page; admin edits the rate elsewhere). */}
|
||||
{/* Rate summary — hidden in compact mode (history-only on the
|
||||
customer detail page). When a caller wires onHourlyRateChange
|
||||
the field is editable; otherwise (the standalone hours page)
|
||||
we show the RESOLVED rate read-only so a disabled input can't
|
||||
masquerade as an editable value, and surface a CTA when no rate
|
||||
is configured anywhere along the chain. */}
|
||||
{!compact && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customers.field.hourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => {
|
||||
if (!onHourlyRateChange) return;
|
||||
if (!Number.isFinite(n)) {
|
||||
onHourlyRateChange(null);
|
||||
return;
|
||||
}
|
||||
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
|
||||
}}
|
||||
disabled={!onHourlyRateChange}
|
||||
className="w-40 input"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('customers.field.hourlyRateHint',
|
||||
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
|
||||
{ currency: profileDefaultCurrency })}
|
||||
</p>
|
||||
{onHourlyRateChange ? (
|
||||
<>
|
||||
<DecimalInput
|
||||
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => {
|
||||
if (!Number.isFinite(n)) { onHourlyRateChange(null); return; }
|
||||
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
|
||||
}}
|
||||
className="w-40 input"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('customers.field.hourlyRateHint',
|
||||
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
|
||||
{ currency: profileDefaultCurrency })}
|
||||
</p>
|
||||
</>
|
||||
) : noRateConfigured ? (
|
||||
<div className="rounded-md border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm">
|
||||
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-200">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('customers.hours.noRate.title', 'No hourly rate configured')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-amber-700 dark:text-amber-300">
|
||||
{t('customers.hours.noRate.body',
|
||||
'Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.')}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<Link to={`/admin/clients/accounts/${customerId}`}
|
||||
className="text-accent-dark hover:underline font-medium">
|
||||
{t('customers.hours.noRate.setForCustomer', 'Set a rate for this customer')}
|
||||
</Link>
|
||||
<Link to="/admin/settings?tab=businessProfile" target="_blank" rel="noopener noreferrer"
|
||||
className="text-accent-dark hover:underline font-medium">
|
||||
{t('customers.hours.noRate.setInstallDefault', 'Set an install-wide default')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-theme">
|
||||
<span className="tabular-nums font-medium">
|
||||
{profileDefaultCurrency} {((effectiveDefaultRateMinor as number) / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-theme ml-2">
|
||||
{customerHourlyRateMinor != null
|
||||
? t('customers.hours.rateSource.customer', 'from this customer')
|
||||
: t('customers.hours.rateSource.installDefault', 'install-wide default')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -270,8 +336,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
inputMode="decimal"
|
||||
value={rateOverride}
|
||||
onChange={(e) => setRateOverride(e.target.value)}
|
||||
placeholder={customerHourlyRateMinor != null
|
||||
? (customerHourlyRateMinor / 100).toFixed(2)
|
||||
placeholder={effectiveDefaultRateMinor != null
|
||||
? (effectiveDefaultRateMinor / 100).toFixed(2)
|
||||
: '—'}
|
||||
className="input w-full" />
|
||||
</div>
|
||||
@@ -286,10 +352,15 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
placeholder={t('customers.hours.form.notePlaceholder',
|
||||
'What was worked on?') as string} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<div className="mt-3 flex items-center justify-end gap-3">
|
||||
{noRateConfigured && !overrideTyped && (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t('customers.hours.form.needRate', 'Set a rate or enter an override to log time.')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={createMutation.isPending}
|
||||
disabled={createMutation.isPending || (noRateConfigured && !overrideTyped)}
|
||||
isLoading={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
@@ -347,7 +418,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const rate = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
|
||||
const rate = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
|
||||
const hours = e.durationMinutes / 60;
|
||||
const total = (hours * rate) / 100;
|
||||
const locked = isLocked(e);
|
||||
|
||||
@@ -2960,7 +2960,8 @@
|
||||
"rateOverride": "Satz-Override",
|
||||
"note": "Notiz / Beschreibung",
|
||||
"notePlaceholder": "Was wurde gearbeitet?",
|
||||
"save": "Eintrag hinzufügen"
|
||||
"save": "Eintrag hinzufügen",
|
||||
"needRate": "Satz festlegen oder Override eingeben, um Zeit zu erfassen."
|
||||
},
|
||||
"col": {
|
||||
"date": "Datum",
|
||||
@@ -2985,6 +2986,20 @@
|
||||
"created": "Eintrag erfasst",
|
||||
"deleted": "Eintrag gelöscht",
|
||||
"billed": "Stunden verrechnet"
|
||||
},
|
||||
"noRate": {
|
||||
"title": "Kein Stundensatz hinterlegt",
|
||||
"body": "Für die Erfassung wird ein Satz benötigt. Legen Sie einen für diesen Kunden fest, geben Sie unten einen Override pro Eintrag ein oder konfigurieren Sie einen installationsweiten Standardsatz.",
|
||||
"setForCustomer": "Satz für diesen Kunden festlegen",
|
||||
"setInstallDefault": "Installationsweiten Standard festlegen"
|
||||
},
|
||||
"rateSource": {
|
||||
"customer": "von diesem Kunden",
|
||||
"installDefault": "installationsweiter Standard"
|
||||
},
|
||||
"error": {
|
||||
"noRate": "Für diesen Kunden ist kein Stundensatz hinterlegt. Geben Sie einen Satz-Override ein, hinterlegen Sie einen Satz beim Kunden oder konfigurieren Sie in den Einstellungen einen installationsweiten Standardsatz.",
|
||||
"createFailed": "Eintrag konnte nicht erfasst werden"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -3687,6 +3702,9 @@
|
||||
"timezone": "Zeitzone (IANA)",
|
||||
"vatLabel": "MwSt-Bezeichnung",
|
||||
"vatRateDefault": "Standard-MwSt-Satz %",
|
||||
"defaultHourlyRate": "Standard-Stundensatz",
|
||||
"defaultHourlyRatePlaceholder": "z. B. 120.00",
|
||||
"defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
|
||||
"defaultQrFormat": "Standard-QR-Format",
|
||||
"footerLine": "Fusszeile"
|
||||
},
|
||||
|
||||
@@ -2960,7 +2960,8 @@
|
||||
"rateOverride": "Rate override",
|
||||
"note": "Note / description",
|
||||
"notePlaceholder": "What was worked on?",
|
||||
"save": "Add entry"
|
||||
"save": "Add entry",
|
||||
"needRate": "Set a rate or enter an override to log time."
|
||||
},
|
||||
"col": {
|
||||
"date": "Date",
|
||||
@@ -2985,6 +2986,20 @@
|
||||
"created": "Entry logged",
|
||||
"deleted": "Entry deleted",
|
||||
"billed": "Hours billed"
|
||||
},
|
||||
"noRate": {
|
||||
"title": "No hourly rate configured",
|
||||
"body": "Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.",
|
||||
"setForCustomer": "Set a rate for this customer",
|
||||
"setInstallDefault": "Set an install-wide default"
|
||||
},
|
||||
"rateSource": {
|
||||
"customer": "from this customer",
|
||||
"installDefault": "install-wide default"
|
||||
},
|
||||
"error": {
|
||||
"noRate": "No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.",
|
||||
"createFailed": "Failed to log entry"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -3684,6 +3699,9 @@
|
||||
"timezone": "Timezone (IANA)",
|
||||
"vatLabel": "VAT label (e.g. MwSt., VAT)",
|
||||
"vatRateDefault": "Default VAT rate %",
|
||||
"defaultHourlyRate": "Default hourly rate",
|
||||
"defaultHourlyRatePlaceholder": "e.g. 120.00",
|
||||
"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.",
|
||||
"defaultQrFormat": "Default invoice QR",
|
||||
"footerLine": "PDF footer line"
|
||||
},
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface BusinessProfile {
|
||||
taxId: string;
|
||||
vatLabel: string;
|
||||
vatRateDefault: number | null;
|
||||
/** Install-wide fallback hourly rate in MINOR units (migration 113).
|
||||
* Last link in the hour-entry rate chain after the per-entry
|
||||
* override and the per-customer default. null = no global default;
|
||||
* the hours page then requires a per-customer or per-entry rate. */
|
||||
defaultHourlyRateMinor: number | null;
|
||||
defaultCurrency: string;
|
||||
defaultLocale: string;
|
||||
defaultQrFormat: QrFormat;
|
||||
|
||||
Reference in New Issue
Block a user