more stripe changes on frontend

This commit is contained in:
2026-01-28 18:35:00 +00:00
parent f70a088a29
commit db2c1dddfe
4 changed files with 8 additions and 22 deletions

View File

@@ -4,5 +4,6 @@ export * from "./organisations";
export * from "./projects"; export * from "./projects";
export * from "./sessions"; export * from "./sessions";
export * from "./sprints"; export * from "./sprints";
export * from "./subscriptions";
export * from "./timed-sessions"; export * from "./timed-sessions";
export * from "./users"; export * from "./users";

View File

@@ -21,13 +21,12 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest)
const { organisationId, fromDate } = parsed.data; const { organisationId, fromDate } = parsed.data;
// Check organisation exists // check organisation exists
const organisation = await getOrganisationById(organisationId); const organisation = await getOrganisationById(organisationId);
if (!organisation) { if (!organisation) {
return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404); return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
} }
// Check user is admin or owner of the organisation
const memberRole = await getOrganisationMemberRole(organisationId, req.userId); const memberRole = await getOrganisationMemberRole(organisationId, req.userId);
if (!memberRole) { if (!memberRole) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403); return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
@@ -38,11 +37,8 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest)
return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403); return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403);
} }
// Get timed sessions for all organisation members
const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate); const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate);
// Enrich with calculated times
// timestamps come from the database as strings, need to convert to Date objects for calculation
const enriched = sessions.map((session) => { const enriched = sessions.map((session) => {
const timestamps = session.timestamps.map((t) => new Date(t)); const timestamps = session.timestamps.map((t) => new Date(t));
return { return {
@@ -51,7 +47,7 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest)
issueId: session.issueId, issueId: session.issueId,
issueNumber: session.issueNumber, issueNumber: session.issueNumber,
projectKey: session.projectKey, projectKey: session.projectKey,
timestamps: session.timestamps, // Return original strings for JSON serialization timestamps: session.timestamps,
endedAt: session.endedAt, endedAt: session.endedAt,
createdAt: session.createdAt, createdAt: session.createdAt,
workTimeMs: calculateWorkTimeMs(timestamps), workTimeMs: calculateWorkTimeMs(timestamps),

View File

@@ -126,42 +126,33 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
}); });
}, [membersData]); }, [membersData]);
// Calculate total time per member and sort by time (greatest to smallest)
const membersWithTimeTracking = useMemo(() => { const membersWithTimeTracking = useMemo(() => {
// Calculate total time per user
const timePerUser = new Map<number, number>(); const timePerUser = new Map<number, number>();
for (const session of timeTrackingData) { for (const session of timeTrackingData) {
const current = timePerUser.get(session.userId) ?? 0; const current = timePerUser.get(session.userId) ?? 0;
timePerUser.set(session.userId, current + (session.workTimeMs ?? 0)); timePerUser.set(session.userId, current + (session.workTimeMs ?? 0));
} }
// Map members with their total time
const membersWithTime = members.map((member) => ({ const membersWithTime = members.map((member) => ({
...member, ...member,
totalTimeMs: timePerUser.get(member.User.id) ?? 0, totalTimeMs: timePerUser.get(member.User.id) ?? 0,
})); }));
// Sort by total time (greatest to smallest), then by role, then by name
const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 }; const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 };
return membersWithTime.sort((a, b) => { return membersWithTime.sort((a, b) => {
// First sort by total time (descending)
if (b.totalTimeMs !== a.totalTimeMs) { if (b.totalTimeMs !== a.totalTimeMs) {
return b.totalTimeMs - a.totalTimeMs; return b.totalTimeMs - a.totalTimeMs;
} }
// Then by role
const roleA = roleOrder[a.OrganisationMember.role] ?? 3; const roleA = roleOrder[a.OrganisationMember.role] ?? 3;
const roleB = roleOrder[b.OrganisationMember.role] ?? 3; const roleB = roleOrder[b.OrganisationMember.role] ?? 3;
if (roleA !== roleB) return roleA - roleB; if (roleA !== roleB) return roleA - roleB;
// Finally by name
return a.User.name.localeCompare(b.User.name); return a.User.name.localeCompare(b.User.name);
}); });
}, [members, timeTrackingData]); }, [members, timeTrackingData]);
// Download time tracking data as CSV or JSON
const downloadTimeTrackingData = (format: "csv" | "json") => { const downloadTimeTrackingData = (format: "csv" | "json") => {
if (!selectedOrganisation) return; if (!selectedOrganisation) return;
// Aggregate data per user
const userData = new Map< const userData = new Map<
number, number,
{ {
@@ -193,8 +184,8 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs); const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs);
// generate CSV or JSON
if (format === "csv") { if (format === "csv") {
// Generate CSV
const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"]; const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"];
const rows = data.map((user) => [ const rows = data.map((user) => [
user.userId, user.userId,
@@ -207,6 +198,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
"\n", "\n",
); );
// download
const blob = new Blob([csv], { type: "text/csv" }); const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -217,7 +209,6 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
// Generate JSON
const json = JSON.stringify( const json = JSON.stringify(
{ {
organisation: selectedOrganisation.Organisation.name, organisation: selectedOrganisation.Organisation.name,
@@ -232,6 +223,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
2, 2,
); );
// download
const blob = new Blob([json], { type: "application/json" }); const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
@@ -894,7 +886,6 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Icon icon="calendar" className="size-4 mr-1" />
From: {fromDate.toLocaleDateString()} From: {fromDate.toLocaleDateString()}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -903,24 +894,21 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
mode="single" mode="single"
selected={fromDate} selected={fromDate}
onSelect={(date) => date && setFromDate(date)} onSelect={(date) => date && setFromDate(date)}
initialFocus autoFocus
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Icon icon="download" className="size-4 mr-1" />
Export Export
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}> <DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
<Icon icon="fileSpreadsheet" className="size-4 mr-2" />
Download CSV Download CSV
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}> <DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
<Icon icon="fileJson" className="size-4 mr-2" />
Download JSON Download JSON
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -419,6 +419,7 @@ export const UserResponseSchema = z.object({
username: z.string(), username: z.string(),
avatarURL: z.string().nullable(), avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]), iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
plan: z.string().nullable().optional(),
createdAt: z.string().nullable().optional(), createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(),
}); });