From 35c46ea460c3bc948da3b2170f67262e97ba3202 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 23 May 2026 20:29:06 +0330 Subject: [PATCH] feat(projects): add per-project rate overrides to access modal --- src/api/projects.ts | 41 +- .../projects/ProjectAccessModal.tsx | 490 +++++++++++++----- src/lib/money.ts | 76 +++ src/pages/Projects.tsx | 11 + 4 files changed, 473 insertions(+), 145 deletions(-) create mode 100644 src/lib/money.ts diff --git a/src/api/projects.ts b/src/api/projects.ts index eb06e43..3db004d 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -12,7 +12,14 @@ export interface ProjectClient { id: string; name: string; } - + +export interface ProjectAccessRateValue { + id: string; + hourly_rate: string; + currency: string; + effective_from: string | null; +} + export interface Project { id: string; name: string; @@ -34,6 +41,8 @@ export interface ProjectAccessItem { is_archived: boolean; client: ProjectClient | null; has_access: boolean; + workspace_rate: ProjectAccessRateValue | null; + project_rate: ProjectAccessRateValue | null; } export interface ProjectAccessState { @@ -41,6 +50,11 @@ export interface ProjectAccessState { user: { id: string; name: string; mobile: string; role: "member" | "guest" }; items: ProjectAccessItem[]; } + +interface ProjectAccessRateMutationResponse { + removed: boolean; + item: ProjectAccessItem; +} export interface ProjectPayload { name: string; @@ -199,3 +213,28 @@ export const grantProjectAccess = async (workspaceId: string, userId: string, pr export const revokeProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) => mutateProjectAccess("/api/projects/access/revoke/", workspaceId, userId, projectIds); + +export const saveProjectAccessRate = async ( + workspaceId: string, + userId: string, + projectId: string, + hourlyRate: string | null, + currency: string, +) => { + const response = await authFetch("/api/projects/access/rate/", { + method: "POST", + body: JSON.stringify({ + workspace: workspaceId, + user: userId, + project: projectId, + hourly_rate: hourlyRate, + currency, + }), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.detail || errorData?.message || "Failed to save project user rate"); + } + invalidateApiCache(["projects", "reports"]); + return response.json() as Promise; +}; diff --git a/src/components/projects/ProjectAccessModal.tsx b/src/components/projects/ProjectAccessModal.tsx index 217fd27..3123ed4 100644 --- a/src/components/projects/ProjectAccessModal.tsx +++ b/src/components/projects/ProjectAccessModal.tsx @@ -20,9 +20,14 @@ import { getProjectAccessState, grantProjectAccess, revokeProjectAccess, + saveProjectAccessRate, type ProjectAccessItem, + type ProjectAccessRateValue, } from "../../api/projects"; +import { getPriceUnits, type PriceUnit } from "../../api/rates"; import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces"; +import { useTranslation } from "../../hooks/useTranslation"; +import { formatRateDisplay } from "../../lib/money"; import { Modal } from "../Modal"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -53,6 +58,22 @@ type Labels = { accessOff: string; loadError: string; saveError: string; + workspaceRate: string; + projectOverride: string; + inheritsWorkspaceRate: string; + noRate: string; + hourlyRatePlaceholder: string; + currencyPlaceholder: string; + removeRate: string; + projectRateSaved: string; + projectRateRemoved: string; + projectRateSaveError: string; + projectRateRemoveError: string; +}; + +type RateDraft = { + hourlyRate: string; + currency: string; }; const MANAGEABLE_ROLES = new Set(["member", "guest"]); @@ -66,6 +87,25 @@ function getMemberName(member: WorkspaceMembership) { ); } +function getPreferredCurrency( + item: Pick, + defaultCurrency: string, +) { + return item.project_rate?.currency || item.workspace_rate?.currency || defaultCurrency; +} + +function getDraftFromItem(item: ProjectAccessItem, defaultCurrency: string): RateDraft { + return { + hourlyRate: item.project_rate?.hourly_rate || "", + currency: getPreferredCurrency(item, defaultCurrency), + }; +} + +function formatRate(rate: ProjectAccessRateValue | null, labels: Labels, lang: "en" | "fa") { + if (!rate) return labels.noRate; + return formatRateDisplay(rate, lang); +} + export function ProjectAccessModal({ isOpen, onClose, @@ -89,9 +129,15 @@ export function ProjectAccessModal({ const [selectedClientId, setSelectedClientId] = useState(""); const [selectedProjectIds, setSelectedProjectIds] = useState([]); const [isSaving, setIsSaving] = useState(false); + const [savingRateProjectId, setSavingRateProjectId] = useState(null); + const [priceUnits, setPriceUnits] = useState([]); + const [rateDrafts, setRateDrafts] = useState>({}); + const { lang } = useTranslation(); const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"; + const defaultCurrency = priceUnits[0]?.code || "USD"; + const manageableMembers = useMemo( () => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)), [members], @@ -143,29 +189,60 @@ export function ProjectAccessModal({ [selectedProjectIds, visibleProjectIds], ); + const currencyOptions = useMemo(() => { + if (priceUnits.length) { + return priceUnits.map((unit) => ({ + value: unit.code, + label: unit.local_name ? `${unit.local_name} (${unit.code})` : `${unit.code} (${unit.name})`, + })); + } + + const fallbackCurrencies = Array.from( + new Set( + projectItems.flatMap((item) => [ + item.project_rate?.currency, + item.workspace_rate?.currency, + defaultCurrency, + ]).filter(Boolean) as string[], + ), + ); + return fallbackCurrencies.map((code) => ({ value: code, label: code })); + }, [defaultCurrency, priceUnits, projectItems]); + useEffect(() => { if (!isOpen) { setSearchQuery(""); setMemberSearchQuery(""); setSelectedClientId(""); setSelectedProjectIds([]); + setRateDrafts({}); return; } - const loadMembers = async () => { + const loadDependencies = async () => { setLoadingMembers(true); - try { - const response = await fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }); - setMembers(response.results || []); - } catch { + const [membersResult, priceUnitsResult] = await Promise.allSettled([ + fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }), + getPriceUnits(), + ]); + + if (membersResult.status === "fulfilled") { + setMembers(membersResult.value.results || []); + } else { toast.error(labels.loadError); setMembers([]); - } finally { - setLoadingMembers(false); } + + if (priceUnitsResult.status === "fulfilled") { + setPriceUnits(priceUnitsResult.value.results || []); + } else { + setPriceUnits([]); + } + + setLoadingMembers(false); }; - void loadMembers(); + void loadDependencies(); }, [isOpen, labels.loadError, workspaceId]); useEffect(() => { @@ -201,6 +278,32 @@ export function ProjectAccessModal({ void loadAccessState(); }, [isOpen, labels.loadError, selectedUserId, workspaceId]); + useEffect(() => { + if (!projectItems.length) { + setRateDrafts({}); + return; + } + + const nextDrafts: Record = {}; + projectItems.forEach((item) => { + nextDrafts[item.id] = getDraftFromItem(item, defaultCurrency); + }); + setRateDrafts(nextDrafts); + }, [defaultCurrency, projectItems]); + + const replaceProjectItem = (nextItem: ProjectAccessItem) => { + setProjectItems((current) => + current.map((item) => (item.id === nextItem.id ? nextItem : item)), + ); + }; + + const syncRateDraftFromItem = (item: ProjectAccessItem) => { + setRateDrafts((current) => ({ + ...current, + [item.id]: getDraftFromItem(item, defaultCurrency), + })); + }; + const toggleProjectSelection = (projectId: string) => { setSelectedProjectIds((current) => current.includes(projectId) @@ -248,6 +351,85 @@ export function ProjectAccessModal({ } }; + const handleRateDraftChange = (projectId: string, patch: Partial) => { + setRateDrafts((current) => ({ + ...current, + [projectId]: { + hourlyRate: current[projectId]?.hourlyRate || "", + currency: current[projectId]?.currency || defaultCurrency, + ...patch, + }, + })); + }; + + const persistProjectRate = async ( + item: ProjectAccessItem, + nextDraft?: Partial, + ) => { + if (!selectedUserId || !item.has_access || savingRateProjectId) return; + + const draft = { + ...(rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency)), + ...nextDraft, + }; + const trimmedRate = draft.hourlyRate.trim(); + const normalizedCurrency = (draft.currency || getPreferredCurrency(item, defaultCurrency)).toUpperCase(); + const currentRate = item.project_rate; + + if (!trimmedRate) { + if (!currentRate) { + syncRateDraftFromItem(item); + return; + } + + setSavingRateProjectId(item.id); + try { + const response = await saveProjectAccessRate( + workspaceId, + selectedUserId, + item.id, + null, + normalizedCurrency, + ); + replaceProjectItem(response.item); + syncRateDraftFromItem(response.item); + toast.success(labels.projectRateRemoved); + } catch (error) { + toast.error(error instanceof Error ? error.message : labels.projectRateRemoveError); + syncRateDraftFromItem(item); + } finally { + setSavingRateProjectId(null); + } + return; + } + + if ( + currentRate?.hourly_rate === trimmedRate && + currentRate?.currency === normalizedCurrency + ) { + return; + } + + setSavingRateProjectId(item.id); + try { + const response = await saveProjectAccessRate( + workspaceId, + selectedUserId, + item.id, + trimmedRate, + normalizedCurrency, + ); + replaceProjectItem(response.item); + syncRateDraftFromItem(response.item); + toast.success(labels.projectRateSaved); + } catch (error) { + toast.error(error instanceof Error ? error.message : labels.projectRateSaveError); + syncRateDraftFromItem(item); + } finally { + setSavingRateProjectId(null); + } + }; + const footer = ( <>
@@ -293,26 +475,10 @@ export function ProjectAccessModal({

- {/* LEFT SIDE */}
- {/* Header */}
@@ -320,14 +486,13 @@ export function ProjectAccessModal({
-
- - +
+ setSearchQuery(event.target.value)} placeholder={labels.searchPlaceholder} - className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow" + className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10" />
@@ -347,7 +512,6 @@ export function ProjectAccessModal({
- {/* Actions */}
- {/* Projects */}
-
- {loadingProjects ? ( -
- - {labels.loading} -
- ) : visibleProjects.length === 0 ? ( -
- {labels.noProjects} -
- ) : ( -
- {visibleProjects.map((item) => { - const isChecked = selectedProjectIds.includes(item.id); + {loadingProjects ? ( +
+ + {labels.loading} +
+ ) : visibleProjects.length === 0 ? ( +
+ {labels.noProjects} +
+ ) : ( +
+ {visibleProjects.map((item) => { + const isChecked = selectedProjectIds.includes(item.id); + const isRateSaving = savingRateProjectId === item.id; + const draft = rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency); - return ( + return ( +
- ); - })} -
- )} -
+ +
+
+
+
+ {labels.workspaceRate} +
+
+ {formatRate(item.workspace_rate, labels, lang)} +
+
+ +
+
+ {labels.projectOverride} +
+
+ {item.project_rate + ? formatRate(item.project_rate, labels, lang) + : labels.inheritsWorkspaceRate} +
+
+
+ +
+
+ + handleRateDraftChange(item.id, { hourlyRate: event.target.value }) + } + onBlur={() => void persistProjectRate(item)} + inputMode="decimal" + placeholder={labels.hourlyRatePlaceholder} + disabled={!item.has_access || isRateSaving} + className="h-10" + /> + - setMemberSearchQuery(event.target.value) - } + onChange={(event) => setMemberSearchQuery(event.target.value)} placeholder={labels.member} - className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow" + className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10" />
- {/* Users List */}
{loadingMembers ? ( @@ -538,59 +745,54 @@ export function ProjectAccessModal({ {labels.noMembers}
) : ( - <> - {filteredMembers.map((member) => { - const isActive = - member.user.id === selectedUserId; + filteredMembers.map((member) => { + const isActive = member.user.id === selectedUserId; - return ( - - ); - })} - + +
+ {member.user.mobile} +
+
+ + ); + }) )}
diff --git a/src/lib/money.ts b/src/lib/money.ts new file mode 100644 index 0000000..ef1c761 --- /dev/null +++ b/src/lib/money.ts @@ -0,0 +1,76 @@ +const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"; + +export const localizeDigits = (value: string, lang: "en" | "fa") => + lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value; + +export const currencyLabel = (currency: string, lang: "en" | "fa") => { + const normalized = currency.toUpperCase(); + if (lang !== "fa") return normalized; + return ( + { + USD: "دلار آمریکا", + EUR: "یورو", + GBP: "پوند", + IRR: "ریال", + IRT: "تومان", + AED: "درهم", + TRY: "لیر", + }[normalized] || normalized + ); +}; + +export const shouldTrimCurrencyDecimals = (currency?: string | null) => { + const normalized = (currency || "").toUpperCase(); + return normalized === "IRR" || normalized === "IRT"; +}; + +export const formatAmountForCurrency = ( + value: string, + currency: string | null | undefined, + lang: "en" | "fa", +) => { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + const normalizedValue = trimmed.replace(/,/g, ""); + const numeric = Number(normalizedValue); + if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); + + const [integerPart, fractionalPart] = normalizedValue.split("."); + const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); + const signed = normalizedValue.startsWith("-") ? `-${grouped}` : grouped; + let formatted = signed; + + if (fractionalPart) { + const nextFraction = shouldTrimCurrencyDecimals(currency) + ? "" + : fractionalPart.replace(/0+$/, ""); + if (nextFraction) { + formatted = `${formatted}.${nextFraction}`; + } + } + + return localizeDigits(formatted, lang); +}; + +export const formatMoneyTotals = ( + totals: { currency: string; amount: string }[], + lang: "en" | "fa", +) => { + if (!totals.length) return "-"; + return totals + .map((item) => `${formatAmountForCurrency(item.amount, item.currency, lang)} ${currencyLabel(item.currency, lang)}`) + .join(" | "); +}; + +export const formatRateDisplay = ( + rate: { amount?: string | null; hourly_rate?: string | null; currency: string; price_unit?: { code?: string; local_name?: string; name?: string } | null } | null, + lang: "en" | "fa", +) => { + if (!rate) return "-"; + const amount = rate.amount ?? rate.hourly_rate ?? ""; + const unitLabel = + lang === "fa" + ? rate.price_unit?.local_name || rate.price_unit?.code || currencyLabel(rate.currency, lang) + : rate.price_unit?.code || rate.currency; + return `${formatAmountForCurrency(amount, rate.currency, lang)} ${unitLabel}`; +}; diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx index 69eb6cb..752bd3c 100644 --- a/src/pages/Projects.tsx +++ b/src/pages/Projects.tsx @@ -483,6 +483,17 @@ export const Projects: React.FC = () => { accessOff: t.projects?.accessOff || "No access", loadError: t.projects?.accessLoadError || "Failed to load project access state.", saveError: t.projects?.accessSaveError || "Failed to update project access.", + workspaceRate: t.rates?.workspaceRate || "Workspace rate", + projectOverride: t.rates?.projectOverride || "Project override", + inheritsWorkspaceRate: t.rates?.inheritsWorkspaceRate || "Inherits workspace rate", + noRate: t.rates?.noRate || "No rate", + hourlyRatePlaceholder: t.rates?.hourlyRatePlaceholder || "0.00", + currencyPlaceholder: t.rates?.currencyPlaceholder || "USD", + removeRate: t.rates?.removeRate || "Remove rate", + projectRateSaved: t.rates?.projectSaveSuccess || "Project user rate saved.", + projectRateRemoved: t.rates?.projectRemoveSuccess || "Project user rate removed.", + projectRateSaveError: t.rates?.projectSaveError || "Failed to save project user rate.", + projectRateRemoveError: t.rates?.projectRemoveError || "Failed to remove project user rate.", }} /> )}