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);
|
||||
|
||||
Reference in New Issue
Block a user