Kunde: persönliche Du/Sie-Präferenz pro Mitarbeiter
Neue Tabelle UserCustomerSalutation (PK userId+customerId,
preference 'DU'|'SIE'). Fehlender Eintrag → Fallback auf
Customer.useInformalAddress (Kunden-Default).
Backend:
- customerService: get/set/clearSalutationPreference mit Fallback-
Logik. Response enthält immer effektive Präferenz + `source`
('user' | 'customer-default'), damit die UI den Standard-Text
anzeigen kann.
- customerController: 3 Endpunkte GET/PUT/DELETE
/:customerId/salutation-preference. userId aus dem JWT, canAccessCustomer
greift.
Frontend:
- customerApi: 3 neue Methoden.
- CustomerDetail: neues Feld "Anrede für mich" mit Du/Sie-Toggle
und Zurücksetzen-Link. Wird nur Mitarbeitern angezeigt, nicht
Portal-Usern.
- Bestehendes Feld "Anrede per" bekommt Hinweis "Standard für alle
Mitarbeiter", damit die Semantik der beiden Felder klar ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -277,12 +277,19 @@ export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Anrede per</dt>
|
||||
<dd>
|
||||
<dd className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={c.useInformalAddress ? 'info' : 'default'}>
|
||||
{c.useInformalAddress ? 'Du (informell)' : 'Sie (formell)'}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-400">Standard für alle Mitarbeiter</span>
|
||||
</dd>
|
||||
</div>
|
||||
{!isCustomerPortal && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Anrede für mich</dt>
|
||||
<dd><SalutationPreferenceToggle customerId={customerId} customerDefaultInformal={!!c.useInformalAddress} /></dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Vorname</dt>
|
||||
<dd className="flex items-center gap-1">
|
||||
@@ -5058,3 +5065,86 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SalutationPreferenceToggle – persönliche Du/Sie-Präferenz des Mitarbeiters
|
||||
// ============================================================================
|
||||
// User-Wunsch 2026-07-04: Stefan darf per Du sein mit einem Kunden, ohne dass
|
||||
// das für andere Mitarbeiter gilt. Wenn kein Eintrag existiert, greift der
|
||||
// Kunden-Default (`useInformalAddress`) – der Toggle zeigt das als "Standard".
|
||||
function SalutationPreferenceToggle({
|
||||
customerId,
|
||||
customerDefaultInformal,
|
||||
}: {
|
||||
customerId: number;
|
||||
customerDefaultInformal: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['salutation-preference', customerId],
|
||||
queryFn: () => customerApi.getSalutationPreference(customerId),
|
||||
});
|
||||
const setMutation = useMutation({
|
||||
mutationFn: (preference: 'DU' | 'SIE') =>
|
||||
customerApi.setSalutationPreference(customerId, preference),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['salutation-preference', customerId] });
|
||||
},
|
||||
});
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => customerApi.clearSalutationPreference(customerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['salutation-preference', customerId] });
|
||||
},
|
||||
});
|
||||
|
||||
const isMutating = setMutation.isPending || clearMutation.isPending;
|
||||
const preference = data?.data?.preference;
|
||||
const source = data?.data?.source;
|
||||
const defaultLabel = customerDefaultInformal ? 'Du' : 'Sie';
|
||||
|
||||
const btnClass = (active: boolean) =>
|
||||
`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
active
|
||||
? 'bg-blue-100 text-blue-800 border border-blue-300'
|
||||
: 'bg-gray-100 text-gray-600 border border-gray-200 hover:bg-gray-200'
|
||||
} ${isMutating ? 'opacity-60 cursor-not-allowed' : ''}`;
|
||||
|
||||
if (isLoading) {
|
||||
return <span className="text-xs text-gray-400">lädt…</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isMutating && setMutation.mutate('DU')}
|
||||
className={btnClass(preference === 'DU')}
|
||||
disabled={isMutating}
|
||||
>
|
||||
Du
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isMutating && setMutation.mutate('SIE')}
|
||||
className={btnClass(preference === 'SIE')}
|
||||
disabled={isMutating}
|
||||
>
|
||||
Sie
|
||||
</button>
|
||||
{source === 'user' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isMutating && clearMutation.mutate()}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
disabled={isMutating}
|
||||
title={`Auf Kunden-Default (${defaultLabel}) zurücksetzen`}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Standard: {defaultLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,6 +205,27 @@ export const customerApi = {
|
||||
const res = await api.delete<ApiResponse<void>>(`/customers/${customerId}/representatives/${representativeId}`);
|
||||
return res.data;
|
||||
},
|
||||
// Persönliche Anrede-Präferenz (Du/Sie) des eingeloggten Mitarbeiters
|
||||
// für einen bestimmten Kunden. Fehlender Override → Kunden-Default.
|
||||
getSalutationPreference: async (customerId: number) => {
|
||||
const res = await api.get<ApiResponse<{ preference: 'DU' | 'SIE'; source: 'user' | 'customer-default' }>>(
|
||||
`/customers/${customerId}/salutation-preference`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
setSalutationPreference: async (customerId: number, preference: 'DU' | 'SIE') => {
|
||||
const res = await api.put<ApiResponse<{ preference: 'DU' | 'SIE'; source: 'user' | 'customer-default' }>>(
|
||||
`/customers/${customerId}/salutation-preference`,
|
||||
{ preference },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
clearSalutationPreference: async (customerId: number) => {
|
||||
const res = await api.delete<ApiResponse<{ preference: 'DU' | 'SIE'; source: 'user' | 'customer-default' }>>(
|
||||
`/customers/${customerId}/salutation-preference`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
searchForRepresentative: async (customerId: number, search: string) => {
|
||||
const res = await api.get<ApiResponse<CustomerSummary[]>>(`/customers/${customerId}/representatives/search`, { params: { search } });
|
||||
return res.data;
|
||||
|
||||
Reference in New Issue
Block a user