feat(projects): add per-project rate overrides to access modal
This commit is contained in:
@@ -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>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
76
src/lib/money.ts
Normal 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}`;
|
||||||
|
};
|
||||||
@@ -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.",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user