feat(pricing): manage workspace member rates in edit flows
This commit is contained in:
130
src/components/rates/WorkspaceMemberRateFields.tsx
Normal file
130
src/components/rates/WorkspaceMemberRateFields.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PriceUnit, WorkspaceUserRate } from "../../api/rates";
|
||||
import {
|
||||
createWorkspaceUserRate,
|
||||
deleteWorkspaceUserRate,
|
||||
updateWorkspaceUserRate,
|
||||
} from "../../api/rates";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
import { Input } from "../ui/input";
|
||||
import { SearchableSelect } from "../ui/SearchableSelect";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
rate?: WorkspaceUserRate;
|
||||
priceUnits: PriceUnit[];
|
||||
onRatesChanged: (updater: (rates: WorkspaceUserRate[]) => WorkspaceUserRate[]) => void;
|
||||
}
|
||||
|
||||
export default function WorkspaceMemberRateFields({
|
||||
workspaceId,
|
||||
userId,
|
||||
rate,
|
||||
priceUnits,
|
||||
onRatesChanged,
|
||||
}: Props) {
|
||||
const { t, lang } = useTranslation();
|
||||
const [hourlyRate, setHourlyRate] = useState(rate?.hourly_rate || "");
|
||||
const [currency, setCurrency] = useState(rate?.currency || "USD");
|
||||
const [isPersisting, setIsPersisting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHourlyRate(rate?.hourly_rate || "");
|
||||
setCurrency(rate?.currency || "USD");
|
||||
}, [rate?.hourly_rate, rate?.currency, rate?.id]);
|
||||
|
||||
const unitOptions = useMemo(
|
||||
() =>
|
||||
priceUnits.map((unit) => ({
|
||||
value: unit.code,
|
||||
label:
|
||||
lang === "fa"
|
||||
? `${unit.local_name || unit.name} (${unit.code})`
|
||||
: `${unit.code} · ${unit.name}`,
|
||||
searchText: `${unit.code} ${unit.name} ${unit.local_name || ""} ${unit.symbol || ""}`,
|
||||
})),
|
||||
[lang, priceUnits],
|
||||
);
|
||||
|
||||
const persist = async (nextRate: string, nextCurrency: string) => {
|
||||
const trimmedRate = nextRate.trim();
|
||||
const normalizedCurrency = nextCurrency.trim().toUpperCase();
|
||||
|
||||
if (!trimmedRate) {
|
||||
if (!rate?.id) return;
|
||||
setIsPersisting(true);
|
||||
try {
|
||||
await deleteWorkspaceUserRate(rate.id);
|
||||
onRatesChanged((rates) => rates.filter((item) => item.id !== rate.id));
|
||||
toast.success(t.rates?.workspaceRemoveSuccess || "Workspace user rate removed.");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : (t.rates?.workspaceRemoveError || "Failed to remove workspace user rate."));
|
||||
setHourlyRate(rate.hourly_rate || "");
|
||||
setCurrency(rate.currency || "USD");
|
||||
} finally {
|
||||
setIsPersisting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (rate?.hourly_rate === trimmedRate && rate?.currency === normalizedCurrency) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPersisting(true);
|
||||
try {
|
||||
const saved = rate?.id
|
||||
? await updateWorkspaceUserRate(rate.id, { hourly_rate: trimmedRate, currency: normalizedCurrency })
|
||||
: await createWorkspaceUserRate({
|
||||
workspace_id: workspaceId,
|
||||
user_id: userId,
|
||||
hourly_rate: trimmedRate,
|
||||
currency: normalizedCurrency,
|
||||
});
|
||||
onRatesChanged((rates) => [
|
||||
...rates.filter((item) => item.user !== userId),
|
||||
saved,
|
||||
]);
|
||||
setHourlyRate(saved.hourly_rate || "");
|
||||
setCurrency(saved.currency || normalizedCurrency);
|
||||
toast.success(t.rates?.workspaceSaveSuccess || "Workspace user rate saved.");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : (t.rates?.workspaceSaveError || "Failed to save workspace user rate."));
|
||||
setHourlyRate(rate?.hourly_rate || "");
|
||||
setCurrency(rate?.currency || "USD");
|
||||
} finally {
|
||||
setIsPersisting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid w-full gap-2 sm:w-auto sm:grid-cols-[120px_180px]">
|
||||
<Input
|
||||
value={hourlyRate}
|
||||
onChange={(event) => setHourlyRate(event.target.value)}
|
||||
onBlur={() => void persist(hourlyRate, currency)}
|
||||
inputMode="decimal"
|
||||
placeholder={t.rates?.hourlyRatePlaceholder || "0.00"}
|
||||
disabled={isPersisting}
|
||||
className="h-9"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={currency}
|
||||
onChange={(value) => {
|
||||
setCurrency(value);
|
||||
if (hourlyRate.trim()) {
|
||||
void persist(hourlyRate, value);
|
||||
}
|
||||
}}
|
||||
options={unitOptions}
|
||||
placeholder={t.rates?.currencyPlaceholder || "USD"}
|
||||
searchPlaceholder={t.rates?.searchUnitPlaceholder || "Search unit..."}
|
||||
disabled={isPersisting}
|
||||
buttonClassName="h-9 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user