fix(reports): add controlled fetching + change chart buckets to localized weekday names

This commit is contained in:
2026-04-28 11:03:51 +03:30
parent 581cfab1ac
commit 599e25e836
4 changed files with 81 additions and 34 deletions

View File

@@ -105,7 +105,13 @@ const getPersianDateParts = (value: Date) => {
}; };
}; };
const getDailyAxisLabel = (date: Date, lang: "en" | "fa") => { const getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => {
if (period === "this_week") {
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
weekday: "short",
}).format(date);
}
if (lang === "fa") { if (lang === "fa") {
return toPersianDigits(String(getPersianDateParts(date).day)); return toPersianDigits(String(getPersianDateParts(date).day));
} }
@@ -140,7 +146,13 @@ const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => {
); );
}; };
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { const buildDailyBuckets = (
fromDate: string,
toDate: string,
existing: ReportChartBucket[],
lang: "en" | "fa",
period: string,
) => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]));
const result: ReportChartBucket[] = []; const result: ReportChartBucket[] = [];
const cursor = parseIsoDate(fromDate); const cursor = parseIsoDate(fromDate);
@@ -152,7 +164,7 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
bucket_label: getDailyAxisLabel(cursor, lang), bucket_label: getDailyAxisLabel(cursor, lang, period),
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
@@ -162,7 +174,7 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
return result.map((bucket) => ({ return result.map((bucket) => ({
...bucket, ...bucket,
bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang), bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period),
})); }));
}; };
@@ -282,7 +294,7 @@ export function ReportsChartPanel({
const buckets = useMonthlyBuckets const buckets = useMonthlyBuckets
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang) ? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang)
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang); : buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang, data.scope.period);
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 92 : 44)); const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 92 : 44));
const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0; const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;

View File

@@ -116,6 +116,7 @@ export function ReportsFilterBar({
tags, tags,
users, users,
canSelectUsers, canSelectUsers,
isLoadingUsers,
labels, labels,
}: { }: {
value: ReportsFilterDraft; value: ReportsFilterDraft;
@@ -125,6 +126,7 @@ export function ReportsFilterBar({
tags: Tag[]; tags: Tag[];
users: WorkspaceMembership[]; users: WorkspaceMembership[];
canSelectUsers: boolean; canSelectUsers: boolean;
isLoadingUsers: boolean;
labels: Record<string, string>; labels: Record<string, string>;
}) { }) {
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
@@ -200,30 +202,36 @@ export function ReportsFilterBar({
</> </>
) : null} ) : null}
{canSelectUsers ? ( {canSelectUsers || isLoadingUsers ? (
<div> <div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400"> <label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{labels.user} {labels.user}
</label> </label>
<SearchableSelect {isLoadingUsers ? (
value={draft.user} <div className="flex h-10 w-full items-center rounded-2xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
onChange={(user) => setDraft((current) => ({ ...current, user }))} {labels.searchUsers || "Loading users..."}
options={[ </div>
{ value: "", label: labels.allUsers }, ) : (
...users.map((membership) => ({ <SearchableSelect
value: membership.user.id, value={draft.user}
label: onChange={(user) => setDraft((current) => ({ ...current, user }))}
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() || options={[
membership.user.email || { value: "", label: labels.allUsers },
membership.user.id, ...users.map((membership) => ({
searchText: membership.user.mobile || "", value: membership.user.id,
})), label:
]} `${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
placeholder={labels.allUsers} membership.user.email ||
searchPlaceholder={labels.searchUsers} membership.user.id,
className="w-full" searchText: membership.user.mobile || "",
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900" })),
/> ]}
placeholder={labels.allUsers}
searchPlaceholder={labels.searchUsers}
className="w-full"
buttonClassName="h-10 rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
/>
)}
</div> </div>
) : null} ) : null}

View File

@@ -164,12 +164,12 @@ export const en = {
statsOwnersAdmins: "Owners & admins", statsOwnersAdmins: "Owners & admins",
statsGuests: "Guests", statsGuests: "Guests",
membersSectionTitle: "Members", membersSectionTitle: "Members",
membersSectionSubtitle: "People in this workspace and their current roles.", membersSectionSubtitle: "People in this workspace and their current roles.",
membersLocked: "Only owners and admins can view the full member list.", membersLocked: "Only owners and admins can view the full member list.",
manageMembers: "Manage members", manageMembers: "Manage members",
mobileNumber: "Mobile Number", mobileNumber: "Mobile Number",
youLabel: "You", youLabel: "You",
resourcesTitle: "Resources", resourcesTitle: "Resources",
resourceOpen: "Open", resourceOpen: "Open",
roleDistributionTitle: "Role distribution", roleDistributionTitle: "Role distribution",
unknownMember: "Unknown member", unknownMember: "Unknown member",

View File

@@ -96,12 +96,15 @@ export default function Reports() {
const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null); const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null);
const [openDay, setOpenDay] = useState<string | null>(null); const [openDay, setOpenDay] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [exportState, setExportState] = useState({ const [exportState, setExportState] = useState({
excel: { pending: false, cooldownSeconds: 0 }, excel: { pending: false, cooldownSeconds: 0 },
pdf: { pending: false, cooldownSeconds: 0 }, pdf: { pending: false, cooldownSeconds: 0 },
}); });
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW); const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role);
const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers);
const [filters, setFilters] = useState<ReportsFilterDraft>({ const [filters, setFilters] = useState<ReportsFilterDraft>({
period: "this_month", period: "this_month",
@@ -117,16 +120,14 @@ export default function Reports() {
if (!activeWorkspace?.id) return; if (!activeWorkspace?.id) return;
const loadOptions = async () => { const loadOptions = async () => {
try { try {
const [projectsResponse, clientsResponse, tagsResponse, membersResponse] = await Promise.all([ const [projectsResponse, clientsResponse, tagsResponse] = await Promise.all([
getProjects(activeWorkspace.id, { limit: 300, offset: 0 }), getProjects(activeWorkspace.id, { limit: 300, offset: 0 }),
getClients(activeWorkspace.id, "", "", 300, 0), getClients(activeWorkspace.id, "", "", 300, 0),
getTags(activeWorkspace.id, { limit: 300, offset: 0 }), getTags(activeWorkspace.id, { limit: 300, offset: 0 }),
fetchWorkspaceMemberships({ workspace: activeWorkspace.id }),
]); ]);
setProjects(projectsResponse.results || []); setProjects(projectsResponse.results || []);
setClients((clientsResponse.results || []).map((client: { id: string; name: string }) => ({ id: client.id, name: client.name }))); setClients((clientsResponse.results || []).map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
setTags(tagsResponse.results || []); setTags(tagsResponse.results || []);
setMemberships(membersResponse.results || []);
} catch { } catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters."); toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
} }
@@ -134,6 +135,31 @@ export default function Reports() {
void loadOptions(); void loadOptions();
}, [activeWorkspace?.id, t.reports?.loadFiltersError]); }, [activeWorkspace?.id, t.reports?.loadFiltersError]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved) return;
if (!canSelectUsers) {
setMemberships([]);
setIsLoadingUsers(false);
return;
}
const loadUsers = async () => {
setIsLoadingUsers(true);
try {
const membersResponse = await fetchWorkspaceMemberships({ workspace: activeWorkspace.id });
setMemberships(membersResponse.results || []);
} catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
setMemberships([]);
} finally {
setIsLoadingUsers(false);
}
};
void loadUsers();
}, [activeWorkspace?.id, canSelectUsers, isWorkspaceRoleResolved, t.reports?.loadFiltersError]);
const buildApiFilters = (draft: ReportsFilterDraft): ReportFilters | null => { const buildApiFilters = (draft: ReportsFilterDraft): ReportFilters | null => {
if (!activeWorkspace?.id) return null; if (!activeWorkspace?.id) return null;
@@ -296,6 +322,7 @@ export default function Reports() {
tags={tags} tags={tags}
users={memberships} users={memberships}
canSelectUsers={canSelectUsers} canSelectUsers={canSelectUsers}
isLoadingUsers={showUserFilterLoading}
labels={{ labels={{
period: t.reports?.period || "Period", period: t.reports?.period || "Period",
thisWeek: t.reports?.periodThisWeek || "This week", thisWeek: t.reports?.periodThisWeek || "This week",