import { useEffect, useMemo, useState } from "react"; import { Briefcase, CheckCheck, CheckCircle2, CheckSquare, FolderTree, Loader2, Search, ShieldAlert, ShieldCheck, Square, UserRound, Users, X, } from "lucide-react"; import { toast } from "sonner"; 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"; import { Select } from "../ui/Select"; type Labels = { title: string; description: string; close: string; member: string; projects: string; loading: string; noMembers: string; noProjects: string; searchPlaceholder: string; allClients: string; selectAllVisible: string; clearSelection: string; selectClientProjects: string; grantSelected: string; revokeSelected: string; accessGranted: string; accessRevoked: string; memberRole: string; client: string; noClient: string; accessOn: string; 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"]); function getMemberName(member: WorkspaceMembership) { return ( member.user?.name || `${member.user?.first_name || ""} ${member.user?.last_name || ""}`.trim() || member.user?.mobile || member.id ); } 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, workspaceId, labels, onApplied, }: { isOpen: boolean; onClose: () => void; workspaceId: string; labels: Labels; onApplied: () => void; }) { const [members, setMembers] = useState([]); const [loadingMembers, setLoadingMembers] = useState(false); const [selectedUserId, setSelectedUserId] = useState(""); const [projectItems, setProjectItems] = useState([]); const [loadingProjects, setLoadingProjects] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [memberSearchQuery, setMemberSearchQuery] = useState(""); 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], ); const filteredMembers = useMemo(() => { const normalizedSearch = memberSearchQuery.trim().toLowerCase(); const baseMembers = !normalizedSearch ? manageableMembers : manageableMembers.filter((member) => { const memberName = getMemberName(member).toLowerCase(); const memberMobile = member.user?.mobile?.toLowerCase() ?? ""; return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch); }); return [...baseMembers].sort((a, b) => { if (a.user.id === selectedUserId) return -1; if (b.user.id === selectedUserId) return 1; return 0; }); }, [manageableMembers, memberSearchQuery, selectedUserId]); const clientOptions = useMemo(() => { const map = new Map(); projectItems.forEach((item) => { if (item.client) { map.set(item.client.id, item.client.name); } }); return Array.from(map.entries()).map(([id, name]) => ({ id, name })); }, [projectItems]); const visibleProjects = useMemo(() => { const normalizedSearch = searchQuery.trim().toLowerCase(); return projectItems.filter((item) => { const matchesClient = !selectedClientId || item.client?.id === selectedClientId; const matchesSearch = !normalizedSearch || item.name.toLowerCase().includes(normalizedSearch) || item.client?.name.toLowerCase().includes(normalizedSearch); return matchesClient && matchesSearch; }); }, [projectItems, searchQuery, selectedClientId]); const visibleProjectIds = useMemo(() => visibleProjects.map((item) => item.id), [visibleProjects]); const selectedVisibleCount = useMemo( () => selectedProjectIds.filter((id) => visibleProjectIds.includes(id)).length, [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 loadDependencies = async () => { setLoadingMembers(true); 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([]); } if (priceUnitsResult.status === "fulfilled") { setPriceUnits(priceUnitsResult.value.results || []); } else { setPriceUnits([]); } setLoadingMembers(false); }; void loadDependencies(); }, [isOpen, labels.loadError, workspaceId]); useEffect(() => { if (!manageableMembers.length) { setSelectedUserId(""); return; } if (!manageableMembers.some((member) => member.user.id === selectedUserId)) { setSelectedUserId(manageableMembers[0].user.id); } }, [manageableMembers, selectedUserId]); useEffect(() => { if (!isOpen || !selectedUserId) { setProjectItems([]); return; } const loadAccessState = async () => { setLoadingProjects(true); try { const response = await getProjectAccessState(workspaceId, selectedUserId); setProjectItems(response.items); setSelectedProjectIds([]); } catch { toast.error(labels.loadError); setProjectItems([]); } finally { setLoadingProjects(false); } }; 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) ? current.filter((id) => id !== projectId) : [...current, projectId], ); }; const handleSelectAllVisible = () => { setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds]))); }; const handleSelectClientProjects = () => { if (!selectedClientId) return; const clientProjectIds = visibleProjects .filter((item) => item.client?.id === selectedClientId) .map((item) => item.id); setSelectedProjectIds((current) => Array.from(new Set([...current, ...clientProjectIds]))); }; const refreshState = async () => { if (!selectedUserId) return; const response = await getProjectAccessState(workspaceId, selectedUserId); setProjectItems(response.items); setSelectedProjectIds([]); onApplied(); }; const handleMutation = async (mode: "grant" | "revoke") => { if (!selectedUserId || !selectedProjectIds.length) return; setIsSaving(true); try { if (mode === "grant") { await grantProjectAccess(workspaceId, selectedUserId, selectedProjectIds); toast.success(labels.accessGranted); } else { await revokeProjectAccess(workspaceId, selectedUserId, selectedProjectIds); toast.success(labels.accessRevoked); } await refreshState(); } catch { toast.error(labels.saveError); } finally { setIsSaving(false); } }; 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 = ( <>
{selectedProjectIds.length}
); return (

{labels.description}

{labels.projects}
setSearchQuery(event.target.value)} placeholder={labels.searchPlaceholder} 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" />
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)} placeholder={labels.member} 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" />
{loadingMembers ? (
{labels.loading}
) : filteredMembers.length === 0 ? (
{labels.noMembers}
) : ( filteredMembers.map((member) => { const isActive = member.user.id === selectedUserId; return ( ); }) )}
); }