feat(projects): add per-project rate overrides to access modal

This commit is contained in:
2026-05-23 20:29:06 +03:30
parent 065360b7a8
commit 35c46ea460
4 changed files with 473 additions and 145 deletions

View File

@@ -12,7 +12,14 @@ export interface ProjectClient {
id: string; id: string;
name: string; name: string;
} }
export interface ProjectAccessRateValue {
id: string;
hourly_rate: string;
currency: string;
effective_from: string | null;
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
@@ -34,6 +41,8 @@ export interface ProjectAccessItem {
is_archived: boolean; is_archived: boolean;
client: ProjectClient | null; client: ProjectClient | null;
has_access: boolean; has_access: boolean;
workspace_rate: ProjectAccessRateValue | null;
project_rate: ProjectAccessRateValue | null;
} }
export interface ProjectAccessState { export interface ProjectAccessState {
@@ -41,6 +50,11 @@ export interface ProjectAccessState {
user: { id: string; name: string; mobile: string; role: "member" | "guest" }; user: { id: string; name: string; mobile: string; role: "member" | "guest" };
items: ProjectAccessItem[]; items: ProjectAccessItem[];
} }
interface ProjectAccessRateMutationResponse {
removed: boolean;
item: ProjectAccessItem;
}
export interface ProjectPayload { export interface ProjectPayload {
name: string; 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[]) => export const revokeProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
mutateProjectAccess("/api/projects/access/revoke/", workspaceId, userId, projectIds); 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<ProjectAccessRateMutationResponse>;
};

View File

@@ -20,9 +20,14 @@ import {
getProjectAccessState, getProjectAccessState,
grantProjectAccess, grantProjectAccess,
revokeProjectAccess, revokeProjectAccess,
saveProjectAccessRate,
type ProjectAccessItem, type ProjectAccessItem,
type ProjectAccessRateValue,
} from "../../api/projects"; } from "../../api/projects";
import { getPriceUnits, type PriceUnit } from "../../api/rates";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces"; import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces";
import { useTranslation } from "../../hooks/useTranslation";
import { formatRateDisplay } from "../../lib/money";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
@@ -53,6 +58,22 @@ type Labels = {
accessOff: string; accessOff: string;
loadError: string; loadError: string;
saveError: 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"]); const MANAGEABLE_ROLES = new Set(["member", "guest"]);
@@ -66,6 +87,25 @@ function getMemberName(member: WorkspaceMembership) {
); );
} }
function getPreferredCurrency(
item: Pick<ProjectAccessItem, "project_rate" | "workspace_rate">,
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({ export function ProjectAccessModal({
isOpen, isOpen,
onClose, onClose,
@@ -89,9 +129,15 @@ export function ProjectAccessModal({
const [selectedClientId, setSelectedClientId] = useState(""); const [selectedClientId, setSelectedClientId] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]); const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
const { lang } = useTranslation();
const isRtl = const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl"; typeof document !== "undefined" && document.documentElement.dir === "rtl";
const defaultCurrency = priceUnits[0]?.code || "USD";
const manageableMembers = useMemo( const manageableMembers = useMemo(
() => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)), () => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)),
[members], [members],
@@ -143,29 +189,60 @@ export function ProjectAccessModal({
[selectedProjectIds, visibleProjectIds], [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(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setSearchQuery(""); setSearchQuery("");
setMemberSearchQuery(""); setMemberSearchQuery("");
setSelectedClientId(""); setSelectedClientId("");
setSelectedProjectIds([]); setSelectedProjectIds([]);
setRateDrafts({});
return; return;
} }
const loadMembers = async () => { const loadDependencies = async () => {
setLoadingMembers(true); setLoadingMembers(true);
try { const [membersResult, priceUnitsResult] = await Promise.allSettled([
const response = await fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }); fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }),
setMembers(response.results || []); getPriceUnits(),
} catch { ]);
if (membersResult.status === "fulfilled") {
setMembers(membersResult.value.results || []);
} else {
toast.error(labels.loadError); toast.error(labels.loadError);
setMembers([]); setMembers([]);
} finally {
setLoadingMembers(false);
} }
if (priceUnitsResult.status === "fulfilled") {
setPriceUnits(priceUnitsResult.value.results || []);
} else {
setPriceUnits([]);
}
setLoadingMembers(false);
}; };
void loadMembers(); void loadDependencies();
}, [isOpen, labels.loadError, workspaceId]); }, [isOpen, labels.loadError, workspaceId]);
useEffect(() => { useEffect(() => {
@@ -201,6 +278,32 @@ export function ProjectAccessModal({
void loadAccessState(); void loadAccessState();
}, [isOpen, labels.loadError, selectedUserId, workspaceId]); }, [isOpen, labels.loadError, selectedUserId, workspaceId]);
useEffect(() => {
if (!projectItems.length) {
setRateDrafts({});
return;
}
const nextDrafts: Record<string, RateDraft> = {};
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) => { const toggleProjectSelection = (projectId: string) => {
setSelectedProjectIds((current) => setSelectedProjectIds((current) =>
current.includes(projectId) current.includes(projectId)
@@ -248,6 +351,85 @@ export function ProjectAccessModal({
} }
}; };
const handleRateDraftChange = (projectId: string, patch: Partial<RateDraft>) => {
setRateDrafts((current) => ({
...current,
[projectId]: {
hourlyRate: current[projectId]?.hourlyRate || "",
currency: current[projectId]?.currency || defaultCurrency,
...patch,
},
}));
};
const persistProjectRate = async (
item: ProjectAccessItem,
nextDraft?: Partial<RateDraft>,
) => {
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 = ( const footer = (
<> <>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"> <div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
@@ -293,26 +475,10 @@ export function ProjectAccessModal({
</p> </p>
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr"> <div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
{/* LEFT SIDE */}
<section <section
dir={isRtl ? "rtl" : "ltr"} dir={isRtl ? "rtl" : "ltr"}
className=" className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-slate-50/40 dark:border-slate-800 dark:bg-slate-950/30 lg:col-span-8"
flex
min-w-0
min-h-[640px]
flex-col
overflow-hidden
rounded-3xl
border
border-slate-200
bg-slate-50/40
dark:border-slate-800
dark:bg-slate-950/30
lg:col-span-8
w-full
"
> >
{/* Header */}
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40"> <div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"> <div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Briefcase className="h-4 w-4" /> <Briefcase className="h-4 w-4" />
@@ -320,14 +486,13 @@ export function ProjectAccessModal({
</div> </div>
<div className="flex flex-col gap-3 xl:items-center"> <div className="flex flex-col gap-3 xl:items-center">
<div className="w-full relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1 w-full">
<Search className="absolute left-3 rtl:left-auto rtl:right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(event) => setSearchQuery(event.target.value)}
placeholder={labels.searchPlaceholder} 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"
/> />
</div> </div>
@@ -347,7 +512,6 @@ export function ProjectAccessModal({
</div> </div>
</div> </div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900"> <div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
<Button <Button
type="button" type="button"
@@ -390,35 +554,37 @@ export function ProjectAccessModal({
</div> </div>
</div> </div>
{/* Projects */}
<div className="min-h-0 flex-1 overflow-y-auto p-4"> <div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="h-full"> {loadingProjects ? (
{loadingProjects ? ( <div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400"> <Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" /> {labels.loading}
{labels.loading} </div>
</div> ) : visibleProjects.length === 0 ? (
) : visibleProjects.length === 0 ? ( <div className="p-5 text-sm text-slate-500 dark:text-slate-400">
<div className="p-5 text-sm text-slate-500 dark:text-slate-400"> {labels.noProjects}
{labels.noProjects} </div>
</div> ) : (
) : ( <div className="grid gap-3">
<div className="grid gap-3"> {visibleProjects.map((item) => {
{visibleProjects.map((item) => { const isChecked = selectedProjectIds.includes(item.id);
const isChecked = selectedProjectIds.includes(item.id); const isRateSaving = savingRateProjectId === item.id;
const draft = rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency);
return ( return (
<div
key={item.id}
className={`rounded-2xl border px-4 py-3 transition ${
isChecked
? "border-sky-200 bg-sky-50/60 shadow-sm dark:border-sky-500/30 dark:bg-sky-500/10"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
}`}
>
<button <button
key={item.id}
type="button" type="button"
onClick={() => toggleProjectSelection(item.id)} onClick={() => toggleProjectSelection(item.id)}
className={`flex w-full items-start gap-3 rounded-2xl border px-4 py-3 text-start transition ${ className="flex w-full items-start gap-3 text-start"
isChecked
? "border-sky-200 bg-sky-50/60 shadow-sm dark:border-sky-500/30 dark:bg-sky-500/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50/70 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:hover:bg-slate-800/40"
}`}
> >
{/* Checkbox */}
<div <div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${ className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
isChecked isChecked
@@ -434,34 +600,19 @@ export function ProjectAccessModal({
)} )}
</div> </div>
{/* Content */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-slate-900 dark:text-slate-100"> <span className="truncate font-medium text-slate-900 dark:text-slate-100">
{item.name} {item.name}
</span> </span>
{/* Better dark mode badge */}
<span <span
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${ className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${
item.has_access item.has_access
? ` ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-1 dark:ring-emerald-400/25"
bg-emerald-100 text-emerald-700 : "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:ring-1 dark:ring-slate-700"
dark:bg-emerald-500/15
dark:text-emerald-200
dark:ring-1 dark:ring-emerald-400/25
`
: `
bg-slate-100 text-slate-600
dark:bg-slate-800
dark:text-slate-200
dark:ring-1 dark:ring-slate-700
`
}`} }`}
> >
{item.has_access {item.has_access ? labels.accessOn : labels.accessOff}
? labels.accessOn
: labels.accessOff}
</span> </span>
</div> </div>
@@ -476,35 +627,95 @@ export function ProjectAccessModal({
) : null} ) : null}
</div> </div>
</button> </button>
);
})} <div className="mt-3 border-t border-slate-200 pt-3 dark:border-slate-800">
</div> <div className="grid gap-2 md:grid-cols-2">
)} <div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
</div> <div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.workspaceRate}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{formatRate(item.workspace_rate, labels, lang)}
</div>
</div>
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.projectOverride}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{item.project_rate
? formatRate(item.project_rate, labels, lang)
: labels.inheritsWorkspaceRate}
</div>
</div>
</div>
<div className="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="grid flex-1 gap-2 sm:grid-cols-[minmax(0,1fr)_200px]">
<Input
value={draft.hourlyRate}
onChange={(event) =>
handleRateDraftChange(item.id, { hourlyRate: event.target.value })
}
onBlur={() => void persistProjectRate(item)}
inputMode="decimal"
placeholder={labels.hourlyRatePlaceholder}
disabled={!item.has_access || isRateSaving}
className="h-10"
/>
<Select
value={draft.currency}
onChange={(value) => {
handleRateDraftChange(item.id, { currency: value });
if (draft.hourlyRate.trim()) {
void persistProjectRate(item, { currency: value });
}
}}
options={currencyOptions}
disabled={!item.has_access || isRateSaving}
className="w-full"
buttonClassName="h-10 w-full rounded-xl"
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="icon"
disabled={!item.has_access || !item.project_rate || isRateSaving}
title={labels.removeRate}
aria-label={labels.removeRate}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
handleRateDraftChange(item.id, { hourlyRate: "" });
void persistProjectRate(item, { hourlyRate: "" });
}}
>
{isRateSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <X className="h-4 w-4" />}
</Button>
</div>
</div>
{!item.has_access && item.project_rate ? (
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{labels.projectOverride}: {formatRate(item.project_rate, labels, lang)}
</div>
) : null}
</div>
</div>
);
})}
</div>
)}
</div> </div>
</section> </section>
{/* RIGHT SIDEBAR */}
<aside <aside
dir={isRtl ? "rtl" : "ltr"} dir={isRtl ? "rtl" : "ltr"}
className=" className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:col-span-4"
flex
min-w-0
min-h-[640px]
flex-col
overflow-hidden
rounded-3xl
border
border-slate-200
bg-white
dark:border-slate-800
dark:bg-slate-900
lg:col-span-4
lg:min-w-[320px]
w-full
"
> >
{/* Sidebar Header */}
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40"> <div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"> <div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
@@ -512,20 +723,16 @@ export function ProjectAccessModal({
</div> </div>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 rtl:left-auto rtl:right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input <Input
value={memberSearchQuery} value={memberSearchQuery}
onChange={(event) => onChange={(event) => setMemberSearchQuery(event.target.value)}
setMemberSearchQuery(event.target.value)
}
placeholder={labels.member} 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"
/> />
</div> </div>
</div> </div>
{/* Users List */}
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2"> <div className="grid gap-2">
{loadingMembers ? ( {loadingMembers ? (
@@ -538,59 +745,54 @@ export function ProjectAccessModal({
{labels.noMembers} {labels.noMembers}
</div> </div>
) : ( ) : (
<> filteredMembers.map((member) => {
{filteredMembers.map((member) => { const isActive = member.user.id === selectedUserId;
const isActive =
member.user.id === selectedUserId;
return ( return (
<button <button
key={member.id} key={member.id}
type="button" type="button"
onClick={() => onClick={() => setSelectedUserId(member.user.id)}
setSelectedUserId(member.user.id) className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${
} isActive
className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${ ? "bg-sky-50/80 dark:bg-sky-500/10"
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/40"
}`}
>
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
isActive isActive
? "bg-sky-50/80 dark:bg-sky-500/10" ? "bg-sky-500 text-white"
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/40" : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300"
}`} }`}
> >
<div <UserRound className="h-4 w-4" />
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${ </div>
isActive
? "bg-sky-500 text-white"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300"
}`}
>
<UserRound className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium text-slate-900 dark:text-slate-100"> <div className="truncate font-medium text-slate-900 dark:text-slate-100">
{getMemberName(member)} {getMemberName(member)}
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{member.role}
</span>
</div> </div>
{isActive ? ( <span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
<CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" /> {member.role}
) : null} </span>
</div> </div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400"> {isActive ? (
{member.user.mobile} <CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" />
</div> ) : null}
</div> </div>
</button>
); <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
})} {member.user.mobile}
</> </div>
</div>
</button>
);
})
)} )}
</div> </div>
</div> </div>

76
src/lib/money.ts Normal file
View File

@@ -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}`;
};

View File

@@ -483,6 +483,17 @@ export const Projects: React.FC = () => {
accessOff: t.projects?.accessOff || "No access", accessOff: t.projects?.accessOff || "No access",
loadError: t.projects?.accessLoadError || "Failed to load project access state.", loadError: t.projects?.accessLoadError || "Failed to load project access state.",
saveError: t.projects?.accessSaveError || "Failed to update project access.", 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.",
}} }}
/> />
)} )}