Compare commits
3 Commits
065360b7a8
...
9a217fcd54
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a217fcd54 | |||
| 993dffb51d | |||
| 35c46ea460 |
@@ -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<ProjectAccessRateMutationResponse>;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,36 @@ export interface WorkspaceUserRate {
|
||||
effective_from: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceProjectRateView {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
client: { id: string; name: string } | null;
|
||||
};
|
||||
rate: {
|
||||
id: string;
|
||||
hourly_rate: string;
|
||||
currency: string;
|
||||
price_unit?: PriceUnit | null;
|
||||
effective_from: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyWorkspaceRatesResponse {
|
||||
workspace: { id: string; name: string };
|
||||
workspace_rate: {
|
||||
id: string;
|
||||
hourly_rate: string;
|
||||
currency: string;
|
||||
price_unit?: PriceUnit | null;
|
||||
effective_from: string | null;
|
||||
} | null;
|
||||
accessible_project_count: number;
|
||||
project_override_count: number;
|
||||
workspace_fallback_project_count: number;
|
||||
project_rates: WorkspaceProjectRateView[];
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
@@ -87,6 +117,15 @@ export const getWorkspaceUserRates = async (workspaceId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getMyWorkspaceRates = async (workspaceId: string) => {
|
||||
const response = await authFetch(`/api/workspaces/${workspaceId}/my-rates/`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to load your workspace rates");
|
||||
}
|
||||
return response.json() as Promise<MyWorkspaceRatesResponse>;
|
||||
};
|
||||
|
||||
export const createWorkspaceUserRate = async (data: {
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
|
||||
@@ -90,7 +90,9 @@ export interface RatePeriodRow {
|
||||
amount: string;
|
||||
currency: string;
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
to_date: string | null;
|
||||
project_name?: string | null;
|
||||
is_current?: boolean;
|
||||
}
|
||||
|
||||
export interface UserReportSummary {
|
||||
@@ -107,6 +109,9 @@ export interface UserReportSummary {
|
||||
project_percentages: PercentageRow[];
|
||||
client_percentages: PercentageRow[];
|
||||
tag_percentages: PercentageRow[];
|
||||
project_income_percentages: PercentageRow[];
|
||||
client_income_percentages: PercentageRow[];
|
||||
tag_income_percentages: PercentageRow[];
|
||||
}
|
||||
|
||||
export interface DayDetailEntry {
|
||||
@@ -143,8 +148,31 @@ export interface TableReportResponse {
|
||||
clients: BreakdownRow[];
|
||||
projects: BreakdownRow[];
|
||||
tags: BreakdownRow[];
|
||||
client_percentages?: PercentageRow[];
|
||||
project_percentages?: PercentageRow[];
|
||||
tag_percentages?: PercentageRow[];
|
||||
client_income_percentages?: PercentageRow[];
|
||||
project_income_percentages?: PercentageRow[];
|
||||
tag_income_percentages?: PercentageRow[];
|
||||
user_summary?: UserReportSummary;
|
||||
user_summaries?: UserReportSummary[];
|
||||
per_user_reports?: UserScopedTableReport[];
|
||||
}
|
||||
|
||||
export interface UserScopedTableReport {
|
||||
scope: ReportScope;
|
||||
summary: ReportSummary;
|
||||
days: DailyReportRow[];
|
||||
clients: BreakdownRow[];
|
||||
projects: BreakdownRow[];
|
||||
tags: BreakdownRow[];
|
||||
client_percentages?: PercentageRow[];
|
||||
project_percentages?: PercentageRow[];
|
||||
tag_percentages?: PercentageRow[];
|
||||
client_income_percentages?: PercentageRow[];
|
||||
project_income_percentages?: PercentageRow[];
|
||||
tag_income_percentages?: PercentageRow[];
|
||||
user_summary: UserReportSummary;
|
||||
}
|
||||
|
||||
export interface ReportExportJob {
|
||||
@@ -211,6 +239,17 @@ export const getDayDetailsReport = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserSummaryReport = async (
|
||||
filters: ReportFilters,
|
||||
userId: string,
|
||||
): Promise<UserScopedTableReport> => {
|
||||
const query = `${toQueryString({ ...filters, user: userId })}`;
|
||||
return cachedGetJson(`/api/reports/user-summary/?${query}`, {
|
||||
ttlMs: 60 * 1000,
|
||||
namespaces: ["reports"],
|
||||
});
|
||||
};
|
||||
|
||||
export const createReportExport = async (
|
||||
filters: ReportFilters,
|
||||
exportType: "excel" | "pdf",
|
||||
|
||||
@@ -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<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({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -89,9 +129,15 @@ export function ProjectAccessModal({
|
||||
const [selectedClientId, setSelectedClientId] = useState("");
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
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 =
|
||||
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<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) => {
|
||||
setSelectedProjectIds((current) =>
|
||||
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 = (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
@@ -293,26 +475,10 @@ export function ProjectAccessModal({
|
||||
</p>
|
||||
|
||||
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
|
||||
{/* LEFT SIDE */}
|
||||
<section
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
className="
|
||||
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
|
||||
"
|
||||
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"
|
||||
>
|
||||
{/* 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="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
@@ -320,14 +486,13 @@ export function ProjectAccessModal({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:items-center">
|
||||
<div className="w-full relative min-w-0 flex-1">
|
||||
<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" />
|
||||
|
||||
<div className="relative min-w-0 flex-1 w-full">
|
||||
<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
|
||||
value={searchQuery}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -347,7 +512,6 @@ export function ProjectAccessModal({
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -390,35 +554,37 @@ export function ProjectAccessModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="h-full">
|
||||
{loadingProjects ? (
|
||||
<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" />
|
||||
{labels.loading}
|
||||
</div>
|
||||
) : visibleProjects.length === 0 ? (
|
||||
<div className="p-5 text-sm text-slate-500 dark:text-slate-400">
|
||||
{labels.noProjects}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{visibleProjects.map((item) => {
|
||||
const isChecked = selectedProjectIds.includes(item.id);
|
||||
{loadingProjects ? (
|
||||
<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" />
|
||||
{labels.loading}
|
||||
</div>
|
||||
) : visibleProjects.length === 0 ? (
|
||||
<div className="p-5 text-sm text-slate-500 dark:text-slate-400">
|
||||
{labels.noProjects}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{visibleProjects.map((item) => {
|
||||
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
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => toggleProjectSelection(item.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-2xl border px-4 py-3 text-start 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 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"
|
||||
}`}
|
||||
className="flex w-full items-start gap-3 text-start"
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div
|
||||
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
|
||||
isChecked
|
||||
@@ -434,34 +600,19 @@ export function ProjectAccessModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-medium text-slate-900 dark:text-slate-100">
|
||||
{item.name}
|
||||
</span>
|
||||
|
||||
{/* Better dark mode badge */}
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${
|
||||
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-slate-100 text-slate-600
|
||||
dark:bg-slate-800
|
||||
dark:text-slate-200
|
||||
dark:ring-1 dark:ring-slate-700
|
||||
`
|
||||
? "bg-emerald-100 text-emerald-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
|
||||
? labels.accessOn
|
||||
: labels.accessOff}
|
||||
{item.has_access ? labels.accessOn : labels.accessOff}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -476,35 +627,95 @@ export function ProjectAccessModal({
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
<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 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>
|
||||
</section>
|
||||
|
||||
{/* RIGHT SIDEBAR */}
|
||||
<aside
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
className="
|
||||
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
|
||||
"
|
||||
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"
|
||||
>
|
||||
{/* 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="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
<Users className="h-4 w-4" />
|
||||
@@ -512,20 +723,16 @@ export function ProjectAccessModal({
|
||||
</div>
|
||||
|
||||
<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
|
||||
value={memberSearchQuery}
|
||||
onChange={(event) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2">
|
||||
{loadingMembers ? (
|
||||
@@ -538,59 +745,54 @@ export function ProjectAccessModal({
|
||||
{labels.noMembers}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredMembers.map((member) => {
|
||||
const isActive =
|
||||
member.user.id === selectedUserId;
|
||||
filteredMembers.map((member) => {
|
||||
const isActive = member.user.id === selectedUserId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedUserId(member.user.id)
|
||||
}
|
||||
className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUserId(member.user.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${
|
||||
isActive
|
||||
? "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
|
||||
? "bg-sky-50/80 dark:bg-sky-500/10"
|
||||
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/40"
|
||||
? "bg-sky-500 text-white"
|
||||
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
|
||||
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>
|
||||
<UserRound className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate font-medium text-slate-900 dark:text-slate-100">
|
||||
{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 className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate font-medium text-slate-900 dark:text-slate-100">
|
||||
{getMemberName(member)}
|
||||
</div>
|
||||
|
||||
{isActive ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" />
|
||||
) : null}
|
||||
<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 className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{member.user.mobile}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" />
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{member.user.mobile}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
196
src/components/rates/WorkspaceRatesPanel.tsx
Normal file
196
src/components/rates/WorkspaceRatesPanel.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Banknote, BriefcaseBusiness, FolderKanban, X } from "lucide-react";
|
||||
|
||||
import type { MyWorkspaceRatesResponse } from "../../api/rates";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
import { formatRateDisplay } from "../../lib/money";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function WorkspaceRatesPanel({
|
||||
open,
|
||||
data,
|
||||
isLoading,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
data: MyWorkspaceRatesResponse | null;
|
||||
isLoading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, lang } = useTranslation();
|
||||
const [shouldRender, setShouldRender] = useState(open);
|
||||
const [isVisible, setIsVisible] = useState(open);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let frameId: number | null = null;
|
||||
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
frameId = window.requestAnimationFrame(() => setIsVisible(true));
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
timeoutId = setTimeout(() => setShouldRender(false), 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (frameId) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-end lg:items-stretch lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-0 cursor-pointer bg-slate-950/40 backdrop-blur-[2px] transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
aria-label="Close rates panel"
|
||||
/>
|
||||
<aside
|
||||
className={`relative z-10 flex max-h-[88vh] w-full flex-col rounded-t-[2rem] bg-white shadow-2xl transition-transform duration-300 ease-out dark:bg-slate-950 lg:h-full lg:max-h-none lg:w-[34rem] lg:rounded-none lg:border-l lg:border-slate-800 ${
|
||||
isVisible ? "translate-y-0 lg:translate-x-0" : "translate-y-full lg:translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t.rates?.myRatesTitle || "My rates"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.myRatesHint || "Project-specific rates override your workspace rate in this workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="rounded-xl">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5">
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
|
||||
{t.loading || "Loading..."}
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
{t.rates?.myRatesEmpty || "No rates are available for this workspace yet."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
|
||||
<Banknote className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
{t.rates?.workspaceRate || "Workspace rate"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.workspaceRateHint || "This is your default rate unless a project-specific rate overrides it."}
|
||||
</p>
|
||||
<div className="mt-3 text-lg font-bold text-slate-900 dark:text-white">
|
||||
{data.workspace_rate
|
||||
? formatRateDisplay(
|
||||
{
|
||||
hourly_rate: data.workspace_rate.hourly_rate,
|
||||
currency: data.workspace_rate.currency,
|
||||
price_unit: data.workspace_rate.price_unit,
|
||||
},
|
||||
lang,
|
||||
)
|
||||
: (t.rates?.noRate || "No rate")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.accessibleProjects || "Accessible projects"}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{data.accessible_project_count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.projectOverrides || "Project overrides"}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{data.project_override_count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.workspaceFallbackProjects || "Using workspace rate"}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{data.workspace_fallback_project_count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
{t.rates?.projectSectionTitle || "Project user rates"}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.projectOverrideHint || "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.project_rates.length ? (
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{data.project_rates.map((projectRate) => (
|
||||
<div key={projectRate.project.id} className="flex items-start gap-4 px-5 py-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-sky-100 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300">
|
||||
<FolderKanban className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{projectRate.project.name}
|
||||
</p>
|
||||
{projectRate.project.client ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||
<BriefcaseBusiness className="h-3 w-3" />
|
||||
{projectRate.project.client.name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{formatRateDisplay(
|
||||
{
|
||||
hourly_rate: projectRate.rate.hourly_rate,
|
||||
currency: projectRate.rate.currency,
|
||||
price_unit: projectRate.rate.price_unit,
|
||||
},
|
||||
lang,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-5 py-6 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t.rates?.projectOverrideEmpty || "You do not have any project-specific rate overrides in this workspace."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,12 @@ type ChartRow = {
|
||||
const localizeDigits = (value: string, lang: "en" | "fa") =>
|
||||
lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value
|
||||
|
||||
const formatAmount = (value: string, lang: "en" | "fa") => {
|
||||
const shouldTrimCurrencyDecimals = (currency?: string | null) => {
|
||||
const normalized = (currency || "").toUpperCase()
|
||||
return normalized === "IRR" || normalized === "IRT"
|
||||
}
|
||||
|
||||
const formatAmount = (value: string, lang: "en" | "fa", currency?: string | null) => {
|
||||
const numeric = Number(value.replace(/,/g, ""))
|
||||
if (Number.isNaN(numeric)) {
|
||||
return localizeDigits(value, lang)
|
||||
@@ -34,7 +39,9 @@ const formatAmount = (value: string, lang: "en" | "fa") => {
|
||||
const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".")
|
||||
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US")
|
||||
const signed = value.startsWith("-") ? `-${grouped}` : grouped
|
||||
const formatted = fractionalPart ? `${signed}.${fractionalPart}` : signed
|
||||
const normalizedFraction =
|
||||
fractionalPart && !shouldTrimCurrencyDecimals(currency) ? fractionalPart.replace(/0+$/, "") : ""
|
||||
const formatted = normalizedFraction ? `${signed}.${normalizedFraction}` : signed
|
||||
return localizeDigits(formatted, lang)
|
||||
}
|
||||
|
||||
@@ -61,7 +68,7 @@ const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ")
|
||||
return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
|
||||
}
|
||||
|
||||
const formatSecondsTick = (value: number, lang: "en" | "fa") => {
|
||||
@@ -260,12 +267,39 @@ function ChartTooltip({
|
||||
export function ReportsChartPanel({
|
||||
data,
|
||||
labels,
|
||||
isLoading,
|
||||
}: {
|
||||
data: ChartReportResponse | null
|
||||
labels: Record<string, string>
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { lang } = useTranslation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
|
||||
{labels.loading}
|
||||
</div>
|
||||
<div className="h-8 animate-pulse rounded-xl bg-slate-200/80 dark:bg-slate-800/80" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">{labels.loading}</div>
|
||||
</div>
|
||||
<div className="h-[320px] animate-pulse rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 sm:h-[360px]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.series.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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}`;
|
||||
};
|
||||
@@ -281,9 +281,10 @@ export const en = {
|
||||
statsRates: "Rates set",
|
||||
statsOwnersAdmins: "Owners & admins",
|
||||
statsGuests: "Guests",
|
||||
membersSectionTitle: "Members",
|
||||
membersSectionSubtitle: "People in this workspace and their current roles.",
|
||||
membersLocked: "Only owners and admins can view the full member list.",
|
||||
membersSectionTitle: "Members",
|
||||
membersSectionSubtitle: "People in this workspace and their current roles.",
|
||||
projectRateHint: "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set a custom rate that overrides the workspace rate for that project.",
|
||||
membersLocked: "Only owners and admins can view the full member list.",
|
||||
manageMembers: "Manage members",
|
||||
mobileNumber: "Mobile Number",
|
||||
youLabel: "You",
|
||||
@@ -592,13 +593,22 @@ export const en = {
|
||||
deleteError: "Failed to delete tag.",
|
||||
},
|
||||
|
||||
rates: {
|
||||
workspaceSectionTitle: "Workspace User Rates",
|
||||
projectSectionTitle: "Project User Rates",
|
||||
workspaceRate: "Workspace rate",
|
||||
projectOverride: "Project override",
|
||||
inheritsWorkspaceRate: "Inherits workspace rate",
|
||||
noRate: "No rate",
|
||||
rates: {
|
||||
workspaceSectionTitle: "Workspace User Rates",
|
||||
projectSectionTitle: "Project User Rates",
|
||||
myRatesTitle: "My rates",
|
||||
myRatesHint: "Project-specific rates override your workspace rate in the current workspace.",
|
||||
workspaceRate: "Workspace rate",
|
||||
workspaceRateHint: "This is your default rate unless a project-specific rate overrides it.",
|
||||
projectOverride: "Project override",
|
||||
projectOverrides: "Project overrides",
|
||||
accessibleProjects: "Accessible projects",
|
||||
workspaceFallbackProjects: "Using workspace rate",
|
||||
projectOverrideHint: "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate.",
|
||||
projectOverrideEmpty: "You do not have any project-specific rate overrides in this workspace.",
|
||||
myRatesEmpty: "No rates are available for this workspace yet.",
|
||||
inheritsWorkspaceRate: "Inherits workspace rate",
|
||||
noRate: "No rate",
|
||||
hourlyRatePlaceholder: "0.00",
|
||||
currencyPlaceholder: "USD",
|
||||
searchUnitPlaceholder: "Search unit...",
|
||||
@@ -726,6 +736,9 @@ export const en = {
|
||||
userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.",
|
||||
rateHistory: "Rate history",
|
||||
percentage: "Percentage",
|
||||
hourPercentage: "Hour %",
|
||||
incomePercentage: "Income %",
|
||||
now: "Now",
|
||||
chartTitle: "Activity chart",
|
||||
totalSeconds: "Total seconds",
|
||||
exportExcel: "Export Excel",
|
||||
|
||||
@@ -285,7 +285,8 @@ export const fa = {
|
||||
membersSectionTitle: "اعضا",
|
||||
membersSectionSubtitle: "اعضای این ورکاسپیس و نقش فعلی آنها.",
|
||||
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
|
||||
manageMembers: "مدیریت اعضا",
|
||||
projectRateHint: "برای هر کاربر میتوانید از صفحه پروژهها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورکاسپیس اولویت داشته باشد.",
|
||||
manageMembers: "مدیریت اعضا",
|
||||
mobileNumber: "شماره تماس",
|
||||
youLabel: "شما",
|
||||
resourcesTitle: "منابع",
|
||||
@@ -589,12 +590,21 @@ export const fa = {
|
||||
deleteError: "حذف تگ با خطا مواجه شد.",
|
||||
},
|
||||
|
||||
rates: {
|
||||
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
||||
projectSectionTitle: "نرخهای کاربران پروژه",
|
||||
workspaceRate: "دستمزد ساعتی",
|
||||
projectOverride: "نرخ اختصاصی پروژه",
|
||||
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
||||
rates: {
|
||||
workspaceSectionTitle: "نرخهای کاربران ورکاسپیس",
|
||||
projectSectionTitle: "نرخهای کاربران پروژه",
|
||||
myRatesTitle: "تعرفههای من",
|
||||
myRatesHint: "نرخهای اختصاصی پروژه در این ورکاسپیس روی نرخ پیشفرض شما اولویت دارند.",
|
||||
workspaceRate: "دستمزد ساعتی",
|
||||
workspaceRateHint: "این نرخ پیشفرض شما است مگر اینکه برای یک پروژه نرخ اختصاصی ثبت شده باشد.",
|
||||
projectOverride: "نرخ اختصاصی پروژه",
|
||||
projectOverrides: "نرخهای اختصاصی پروژه",
|
||||
accessibleProjects: "پروژههای دردسترس",
|
||||
workspaceFallbackProjects: "با نرخ ورکاسپیس",
|
||||
projectOverrideHint: "فقط پروژههایی که نرخ اختصاصی دارند اینجا نمایش داده میشوند. بقیه پروژههای دردسترس از نرخ ورکاسپیس استفاده میکنند.",
|
||||
projectOverrideEmpty: "برای شما در این ورکاسپیس هنوز نرخ اختصاصی پروژهای ثبت نشده است.",
|
||||
myRatesEmpty: "هنوز نرخی برای این ورکاسپیس ثبت نشده است.",
|
||||
inheritsWorkspaceRate: "ارثبری از دستمزد ساعتی",
|
||||
noRate: "بدون نرخ",
|
||||
hourlyRatePlaceholder: "0.00",
|
||||
currencyPlaceholder: "USD",
|
||||
@@ -722,6 +732,9 @@ export const fa = {
|
||||
userSummaryDetailsDescription: "تاریخچه نرخهای ساعتی و توزیع زمان کار برای کاربر انتخابشده را بررسی کنید.",
|
||||
rateHistory: "تاریخچه نرخها",
|
||||
percentage: "درصد",
|
||||
hourPercentage: "درصد ساعت",
|
||||
incomePercentage: "درصد کارکرد",
|
||||
now: "حال",
|
||||
chartTitle: "نمودار فعالیت",
|
||||
totalSeconds: "مجموع ثانیه",
|
||||
exportExcel: "خروجی Excel",
|
||||
|
||||
@@ -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.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
getChartReport,
|
||||
getDayDetailsReport,
|
||||
getTableReport,
|
||||
getUserSummaryReport,
|
||||
type ChartReportResponse,
|
||||
type DayDetailsResponse,
|
||||
type ReportFilters,
|
||||
type TableReportResponse,
|
||||
type UserScopedTableReport,
|
||||
} from "../api/reports";
|
||||
import { getTags, type Tag } from "../api/tags";
|
||||
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
|
||||
@@ -262,6 +264,13 @@ export default function Reports() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadUserSummaryReport = async (userId: string): Promise<UserScopedTableReport> => {
|
||||
if (!apiFilters) {
|
||||
throw new Error("Missing report filters");
|
||||
}
|
||||
return getUserSummaryReport(apiFilters, userId);
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -362,13 +371,10 @@ export default function Reports() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
|
||||
{t.loading || "Loading..."}
|
||||
</div>
|
||||
) : tab === "chart" ? (
|
||||
{tab === "chart" ? (
|
||||
<ReportsChartPanel
|
||||
data={chartData}
|
||||
isLoading={isLoading}
|
||||
labels={{
|
||||
totalHours: t.reports?.totalHours || "Total hours",
|
||||
billableHours: t.reports?.billableHours || "Billable hours",
|
||||
@@ -376,6 +382,7 @@ export default function Reports() {
|
||||
totalIncome: t.reports?.totalIncome || "Total income",
|
||||
chart: t.reports?.chartTitle || "Activity chart",
|
||||
totalSeconds: t.reports?.totalSeconds || "Total seconds",
|
||||
loading: t.loading || "Loading...",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -384,8 +391,10 @@ export default function Reports() {
|
||||
dayDetails={dayDetails}
|
||||
openDay={openDay}
|
||||
onToggleDay={(day) => void handleToggleDay(day)}
|
||||
onLoadUserSummaryReport={(userId) => handleLoadUserSummaryReport(userId)}
|
||||
onExport={(type) => void handleExport(type)}
|
||||
exportState={exportState}
|
||||
isLoading={isLoading}
|
||||
labels={{
|
||||
exportExcel: t.reports?.exportExcel || "Export Excel",
|
||||
exportPdf: t.reports?.exportPdf || "Export PDF",
|
||||
@@ -409,13 +418,19 @@ export default function Reports() {
|
||||
userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}",
|
||||
userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.",
|
||||
rateHistory: t.reports?.rateHistory || "Rate history",
|
||||
project: t.reports?.project || "Project",
|
||||
fromDate: t.reports?.fromDate || "From",
|
||||
toDate: t.reports?.toDate || "To",
|
||||
now: t.reports?.now || "Now",
|
||||
loadDetailsError: t.reports?.loadError || "Failed to load user report details.",
|
||||
hourPercentage: t.reports?.hourPercentage || "Hour %",
|
||||
incomePercentage: t.reports?.incomePercentage || "Income %",
|
||||
noData: t.reports?.noData || "No data",
|
||||
clientsTable: t.reports?.clientsTable || "Clients",
|
||||
projectsTable: t.reports?.projectsTable || "Projects",
|
||||
tagsTable: t.reports?.tagsTable || "Tags",
|
||||
noDescription: t.timesheet?.emptyDescription || "No description",
|
||||
loading: t.loading || "Loading...",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||
import { Banknote, CalendarDays, Check, ChevronDown, Clock3, DollarSign, MoreVertical, Pencil, Play, Plus, Search, Square, Tag as TagIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getProjects, type Project } from "../api/projects";
|
||||
import {
|
||||
import { getProjects, type Project } from "../api/projects";
|
||||
import { getMyWorkspaceRates, type MyWorkspaceRatesResponse } from "../api/rates";
|
||||
import {
|
||||
createTimeEntry,
|
||||
deleteTimeEntry,
|
||||
getTimeEntries,
|
||||
@@ -18,9 +19,10 @@ import {
|
||||
} from "../api/timeEntries";
|
||||
import { getTags, type Tag } from "../api/tags";
|
||||
import { Modal } from "../components/Modal";
|
||||
import EmptyStateCard from "../components/EmptyStateCard";
|
||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||||
import EmptyStateCard from "../components/EmptyStateCard";
|
||||
import { InfiniteScroll } from "../components/InfiniteScroll";
|
||||
import { WorkspaceRatesPanel } from "../components/rates/WorkspaceRatesPanel";
|
||||
import TimesheetFilterBar, { type TimeEntryFilters } from "../components/timesheet/TimesheetFilterBar";
|
||||
import JalaliDatePicker from "../components/ui/JalaliDatePicker";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -2015,10 +2017,13 @@ function TimesheetSkeleton({ loadingLabel }: { loadingLabel: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Timesheet() {
|
||||
const { t, lang } = useTranslation();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
export default function Timesheet() {
|
||||
const { t, lang } = useTranslation();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const [isRatesPanelOpen, setIsRatesPanelOpen] = useState(false);
|
||||
const [isRatesPanelLoading, setIsRatesPanelLoading] = useState(false);
|
||||
const [myRates, setMyRates] = useState<MyWorkspaceRatesResponse | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isRtl = lang === "fa";
|
||||
const extendedTimesheet = (t.timesheet as {
|
||||
deleteTitle?: string;
|
||||
@@ -2158,11 +2163,17 @@ export default function Timesheet() {
|
||||
void loadOptions();
|
||||
}, [activeWorkspace?.id, t.timesheet?.optionsError]);
|
||||
|
||||
useEffect(() => {
|
||||
setGroupedHistory([]);
|
||||
setNextOffset(0);
|
||||
setHasMoreHistory(false);
|
||||
}, [activeWorkspace?.id]);
|
||||
useEffect(() => {
|
||||
setGroupedHistory([]);
|
||||
setNextOffset(0);
|
||||
setHasMoreHistory(false);
|
||||
}, [activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsRatesPanelOpen(false);
|
||||
setIsRatesPanelLoading(false);
|
||||
setMyRates(null);
|
||||
}, [activeWorkspace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -2595,10 +2606,29 @@ export default function Timesheet() {
|
||||
setDebouncedSearchQuery("");
|
||||
}, [setSearchParams]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
||||
void loadHistory({ offset: nextOffset, append: true });
|
||||
}, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]);
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!hasMoreHistory || nextOffset === null || isLoadingMore) return;
|
||||
void loadHistory({ offset: nextOffset, append: true });
|
||||
}, [hasMoreHistory, isLoadingMore, loadHistory, nextOffset]);
|
||||
|
||||
const openRatesPanel = useCallback(async () => {
|
||||
if (!activeWorkspace?.id) return;
|
||||
|
||||
setIsRatesPanelOpen(true);
|
||||
if (myRates || isRatesPanelLoading) return;
|
||||
|
||||
try {
|
||||
setIsRatesPanelLoading(true);
|
||||
const response = await getMyWorkspaceRates(activeWorkspace.id);
|
||||
setMyRates(response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t.rates?.projectSaveError || "Failed to load rates.");
|
||||
setIsRatesPanelOpen(false);
|
||||
} finally {
|
||||
setIsRatesPanelLoading(false);
|
||||
}
|
||||
}, [activeWorkspace?.id, isRatesPanelLoading, myRates, t.rates?.projectSaveError]);
|
||||
|
||||
const handleDiscardTimerDraft = useCallback(async () => {
|
||||
if (!discardTimerModal.entry || isDiscardingTimer) return;
|
||||
@@ -2628,12 +2658,16 @@ export default function Timesheet() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-73px)] flex-col bg-slate-100/70 p-4 dark:bg-slate-900">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.timesheet?.title || 'Timesheets'}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.timesheet?.description(activeWorkspace?.name || "-") || 'Manage your Timesheet'}</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void openRatesPanel()} className="gap-2">
|
||||
<Banknote className="h-4 w-4" />
|
||||
{t.rates?.myRatesTitle || "My rates"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={desktopTimerRef}
|
||||
@@ -3066,8 +3100,8 @@ export default function Timesheet() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{discardTimerModal.entry && (
|
||||
<Modal
|
||||
{discardTimerModal.entry && (
|
||||
<Modal
|
||||
isOpen={discardTimerModal.isOpen}
|
||||
onClose={closeDiscardTimerModal}
|
||||
title={(t.actions as { discard?: string } | undefined)?.discard || "Discard"}
|
||||
@@ -3096,8 +3130,15 @@ export default function Timesheet() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<WorkspaceRatesPanel
|
||||
open={isRatesPanelOpen}
|
||||
data={myRates}
|
||||
isLoading={isRatesPanelLoading}
|
||||
onClose={() => setIsRatesPanelOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import { useWorkspace } from '../context/WorkspaceContext';
|
||||
import { useTranslation } from '../hooks/useTranslation';
|
||||
import { formatRateDisplay } from '../lib/money';
|
||||
import {
|
||||
CLIENTS_VIEW,
|
||||
PROJECTS_VIEW,
|
||||
@@ -188,14 +189,17 @@ export default function WorkspaceDetail() {
|
||||
return `${firstName}${lastName}`.trim().toUpperCase() || getMemberName(member).charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const formatRateUnit = (rate?: WorkspaceUserRate) => {
|
||||
if (!rate) return t.rates?.noRate || 'No rate';
|
||||
const unitLabel =
|
||||
lang === 'fa'
|
||||
? rate.price_unit?.local_name || rate.price_unit?.code || rate.currency
|
||||
: rate.price_unit?.code || rate.currency;
|
||||
return `${rate.hourly_rate} ${unitLabel}`;
|
||||
};
|
||||
const formatRateUnit = (rate?: WorkspaceUserRate) =>
|
||||
rate
|
||||
? formatRateDisplay(
|
||||
{
|
||||
hourly_rate: rate.hourly_rate,
|
||||
currency: rate.currency,
|
||||
price_unit: rate.price_unit,
|
||||
},
|
||||
lang,
|
||||
)
|
||||
: (t.rates?.noRate || 'No rate');
|
||||
|
||||
const workspaceRole = workspace?.my_role;
|
||||
const canEdit = canWorkspace(workspaceRole, WORKSPACE_EDIT);
|
||||
|
||||
@@ -432,11 +432,19 @@ export default function EditWorkspace() {
|
||||
|
||||
<div className="w-full rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:flex lg:w-2/3 lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<div className="p-6 shrink-0 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 z-10">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
</h2>
|
||||
|
||||
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{ t.workspace?.members || "Members" }
|
||||
</h2>
|
||||
|
||||
<div className="mb-4 flex items-start gap-3 rounded-xl border border-sky-100 bg-sky-50/80 px-4 py-3 text-sm text-sky-900 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="leading-6">
|
||||
{t.workspace?.projectRateHint ||
|
||||
"Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set an override rate for a specific member."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canWorkspace(myRole, WORKSPACE_MEMBERS_ADD) && (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
Reference in New Issue
Block a user