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") {
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 result: ReportChartBucket[] = [];
const cursor = parseIsoDate(fromDate);
@@ -152,7 +164,7 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: getDailyAxisLabel(cursor, lang),
bucket_label: getDailyAxisLabel(cursor, lang, period),
total_seconds: 0,
total_duration: "00:00:00",
},
@@ -162,7 +174,7 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
return result.map((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
? 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 interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;

View File

@@ -116,6 +116,7 @@ export function ReportsFilterBar({
tags,
users,
canSelectUsers,
isLoadingUsers,
labels,
}: {
value: ReportsFilterDraft;
@@ -125,6 +126,7 @@ export function ReportsFilterBar({
tags: Tag[];
users: WorkspaceMembership[];
canSelectUsers: boolean;
isLoadingUsers: boolean;
labels: Record<string, string>;
}) {
const [draft, setDraft] = useState(value);
@@ -200,30 +202,36 @@ export function ReportsFilterBar({
</>
) : null}
{canSelectUsers ? (
{canSelectUsers || isLoadingUsers ? (
<div>
<label className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
{labels.user}
</label>
<SearchableSelect
value={draft.user}
onChange={(user) => setDraft((current) => ({ ...current, user }))}
options={[
{ value: "", label: labels.allUsers },
...users.map((membership) => ({
value: membership.user.id,
label:
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
membership.user.email ||
membership.user.id,
searchText: membership.user.mobile || "",
})),
]}
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"
/>
{isLoadingUsers ? (
<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">
{labels.searchUsers || "Loading users..."}
</div>
) : (
<SearchableSelect
value={draft.user}
onChange={(user) => setDraft((current) => ({ ...current, user }))}
options={[
{ value: "", label: labels.allUsers },
...users.map((membership) => ({
value: membership.user.id,
label:
`${membership.user.first_name || ""} ${membership.user.last_name || ""}`.trim() ||
membership.user.email ||
membership.user.id,
searchText: membership.user.mobile || "",
})),
]}
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>
) : null}

View File

@@ -164,12 +164,12 @@ export const en = {
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.",
manageMembers: "Manage members",
mobileNumber: "Mobile Number",
youLabel: "You",
resourcesTitle: "Resources",
membersSectionSubtitle: "People in this workspace and their current roles.",
membersLocked: "Only owners and admins can view the full member list.",
manageMembers: "Manage members",
mobileNumber: "Mobile Number",
youLabel: "You",
resourcesTitle: "Resources",
resourceOpen: "Open",
roleDistributionTitle: "Role distribution",
unknownMember: "Unknown member",

View File

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