diff --git a/apps/web/playwright/out-of-office.e2e.ts b/apps/web/playwright/out-of-office.e2e.ts index 2dc6ee8be5..3290994ec7 100644 --- a/apps/web/playwright/out-of-office.e2e.ts +++ b/apps/web/playwright/out-of-office.e2e.ts @@ -660,9 +660,7 @@ test.describe("Out of office", () => { await page.locator('[data-testid="add-filter-button"]').click(); await page.locator('[data-testid="add-filter-item-dateRange"]').click(); await expect( - page - .locator('[data-testid="filter-popover-trigger-dateRange"] span', { hasText: "Last 7 Days" }) - .nth(0) + page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first() ).toBeVisible(); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1949e5c654..b23a9f51b6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3093,6 +3093,9 @@ "enable_delegation_credential_description": "Grant Cal.com automatic access to the calendars of all organization members by enabling delegation credential.", "disable_delegation_credential": "Disable Delegation Credential", "disable_delegation_credential_description": "Once delegation credential is disabled, organization members who haven’t connected their calendars will need to do so manually.", + "last_active": "Last Active", + "member_since": "Member Since", + "last_updated": "Last Updated", "salesforce_on_cancel_write_to_event": "On cancelled booking, write to event record instead of deleting event", "salesforce_on_every_cancellation": "On every cancellation", "report_issue": "Report issue", diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts index 3c0b1309f6..65987d2415 100644 --- a/apps/web/test/lib/generateCsv.test.ts +++ b/apps/web/test/lib/generateCsv.test.ts @@ -68,6 +68,8 @@ describe("generate Csv for Org Users Table", () => { teams: [], attributes: [], lastActiveAt: "", + createdAt: null, + updatedAt: null, }; it("should throw if no headers", () => { diff --git a/packages/features/data-table/components/filters/ActiveFilters.tsx b/packages/features/data-table/components/filters/ActiveFilters.tsx index cb7781e3c4..23cd495931 100644 --- a/packages/features/data-table/components/filters/ActiveFilters.tsx +++ b/packages/features/data-table/components/filters/ActiveFilters.tsx @@ -30,6 +30,7 @@ export function ActiveFilters({ table }: ActiveFiltersProps) { key={column.id} column={column} options={column.dateRangeOptions} + showColumnName showClearButton /> ); diff --git a/packages/features/data-table/components/filters/DateRangeFilter.tsx b/packages/features/data-table/components/filters/DateRangeFilter.tsx index 0988e5c5eb..2456400bbd 100644 --- a/packages/features/data-table/components/filters/DateRangeFilter.tsx +++ b/packages/features/data-table/components/filters/DateRangeFilter.tsx @@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from "react"; import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import classNames from "@calcom/ui/classNames"; +import { Badge } from "@calcom/ui/components/badge"; import { Button, buttonClasses } from "@calcom/ui/components/button"; import { Command, @@ -35,14 +36,21 @@ import { useFilterPopoverOpen } from "./useFilterPopoverOpen"; type DateRangeFilterProps = { column: Extract; options?: DateRangeFilterOptions; + showColumnName?: boolean; showClearButton?: boolean; }; -export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => { +export const DateRangeFilter = ({ + column, + options, + showColumnName = false, + showClearButton = false, +}: DateRangeFilterProps) => { const { open, onOpenChange } = useFilterPopoverOpen(column.id); const filterValue = useFilterValue(column.id, ZDateRangeFilterValue); const { updateFilter, removeFilter } = useDataTable(); const range = options?.range ?? "past"; + const endOfDay = options?.endOfDay ?? false; const forceCustom = range === "custom"; const forcePast = range === "past"; @@ -73,13 +81,13 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da type: ColumnFilterType.DATE_RANGE, data: { startDate: startDate.toDate().toISOString(), - endDate: endDate.toDate().toISOString(), + endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(), preset: preset.value, }, }); } }, - [column.id] + [column.id, endOfDay] ); useEffect(() => { @@ -137,6 +145,10 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`; } + const selectedValue = isCustomPreset + ? customButtonLabel + : t(selectedPreset.labelKey, selectedPreset.i18nOptions); + return ( @@ -146,8 +158,15 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da StartIcon="calendar-range" EndIcon="chevron-down" data-testid={`filter-popover-trigger-${column.id}`}> - {!isCustomPreset && {t(selectedPreset.labelKey, selectedPreset.i18nOptions)}} - {isCustomPreset && {customButtonLabel}} + {showColumnName && ( + <> + {column.title} + + {selectedValue} + + + )} + {!showColumnName && {selectedValue}} diff --git a/packages/features/data-table/lib/server.ts b/packages/features/data-table/lib/server.ts index 1baa98979e..1547653f5b 100644 --- a/packages/features/data-table/lib/server.ts +++ b/packages/features/data-table/lib/server.ts @@ -4,6 +4,7 @@ import { isMultiSelectFilterValue, isTextFilterValue, isNumberFilterValue, + isDateRangeFilterValue, } from "./utils"; type MakeWhereClauseProps = { @@ -154,6 +155,19 @@ export function makeWhereClause(props: MakeWhereClauseProps) { default: throw new Error(`Invalid operator for number filter: ${operator}`); } + } else if (isDateRangeFilterValue(filterValue)) { + const { startDate, endDate } = filterValue.data; + if (!startDate || !endDate) { + throw new Error(`Invalid date range filter: ${JSON.stringify({ columnName, startDate, endDate })}`); + } + + return { + [columnName]: { + ...jsonPathObj, + gte: startDate, + lte: endDate, + }, + }; } throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`); } diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index f7e1603647..2d85785b09 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -113,7 +113,8 @@ export const ZFilterValue = z.union([ ]); export type DateRangeFilterOptions = { - range: "past" | "custom"; + range?: "past" | "custom"; + endOfDay?: boolean; }; export type TextFilterOptions = { diff --git a/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx b/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx index 4391da1fd6..8743f44f53 100644 --- a/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx +++ b/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx @@ -131,7 +131,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) enableHiding: false, size: 200, header: () => { - return `Managed Users`; + return t("managed_users"); }, cell: ({ row }) => { if (isPending) { @@ -166,7 +166,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) { id: "role", accessorFn: (data) => data.role, - header: "Role", + header: t("role"), size: 100, cell: ({ row, table }) => { if (isPending) { @@ -188,7 +188,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) { id: "teams", accessorFn: (data) => data.teams.map((team) => team.name), - header: "Teams", + header: t("teams"), size: 140, cell: ({ row, table }) => { if (isPending) { diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index e885b57f30..694e377f1b 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -74,6 +74,8 @@ const initalColumnVisibility = { member: true, role: true, teams: true, + createdAt: false, + updatedAt: false, actions: true, }; @@ -366,9 +368,52 @@ function UserListTableContent() { ...generateAttributeColumns(), { id: "lastActiveAt", - header: "Last Active", + accessorKey: "lastActiveAt", + header: t("last_active"), + enableSorting: false, + enableColumnFilter: true, + meta: { + filter: { + type: ColumnFilterType.DATE_RANGE, + dateRangeOptions: { + endOfDay: true, + }, + }, + }, cell: ({ row }) =>
{row.original.lastActiveAt}
, }, + { + id: "createdAt", + accessorKey: "createdAt", + header: t("member_since"), + enableSorting: false, + enableColumnFilter: true, + meta: { + filter: { + type: ColumnFilterType.DATE_RANGE, + dateRangeOptions: { + endOfDay: true, + }, + }, + }, + cell: ({ row }) =>
{row.original.createdAt || ""}
, + }, + { + id: "updatedAt", + accessorKey: "updatedAt", + header: t("last_updated"), + enableSorting: false, + enableColumnFilter: true, + meta: { + filter: { + type: ColumnFilterType.DATE_RANGE, + dateRangeOptions: { + endOfDay: true, + }, + }, + }, + cell: ({ row }) =>
{row.original.updatedAt || ""}
, + }, { id: "actions", enableHiding: false, diff --git a/packages/prisma/migrations/20250407204810_add_membership_creation_date/migration.sql b/packages/prisma/migrations/20250407204810_add_membership_creation_date/migration.sql new file mode 100644 index 0000000000..dbc42dcf7d --- /dev/null +++ b/packages/prisma/migrations/20250407204810_add_membership_creation_date/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Membership" ADD COLUMN "createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN "updatedAt" TIMESTAMP(3); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 22ec5085d8..6c646f5f71 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -546,6 +546,8 @@ model Membership { user User @relation(fields: [userId], references: [id], onDelete: Cascade) disableImpersonation Boolean @default(false) AttributeToUser AttributeToUser[] + createdAt DateTime? @default(now()) + updatedAt DateTime? @updatedAt @@unique([userId, teamId]) @@index([teamId]) diff --git a/packages/prisma/seed-insights.ts b/packages/prisma/seed-insights.ts index 1b1b6749ef..c145dd8dcc 100644 --- a/packages/prisma/seed-insights.ts +++ b/packages/prisma/seed-insights.ts @@ -188,6 +188,8 @@ async function main() { userId: member.user.id, role: member.role, accepted: true, + createdAt: new Date(), + updatedAt: new Date(), })), }); } @@ -424,6 +426,8 @@ async function createPerformanceData() { userId: memberId.id, role: "MEMBER", accepted: true, + createdAt: new Date(), + updatedAt: new Date(), })), }); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index bd28cf47e8..b5a6cbcd3b 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -83,6 +83,15 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { const teamFilter = filters.find((filter) => filter.id === "teams") as | TypedColumnFilter | undefined; + const lastActiveAtFilter = filters.find((filter) => filter.id === "lastActiveAt") as + | TypedColumnFilter + | undefined; + const createdAtFilter = filters.find((filter) => filter.id === "createdAt") as + | TypedColumnFilter + | undefined; + const updatedAtFilter = filters.find((filter) => filter.id === "updatedAt") as + | TypedColumnFilter + | undefined; const whereClause: Prisma.MembershipWhereInput = { user: { @@ -97,6 +106,11 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { }, }, }), + ...(lastActiveAtFilter && + makeWhereClause({ + columnName: "lastActiveAt", + filterValue: lastActiveAtFilter.value, + })), }, teamId: organizationId, ...(searchTerm && { @@ -112,10 +126,27 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { columnName: "role", filterValue: roleFilter.value, })), + ...(createdAtFilter && + makeWhereClause({ + columnName: "createdAt", + filterValue: createdAtFilter.value, + })), + ...(updatedAtFilter && + makeWhereClause({ + columnName: "updatedAt", + filterValue: updatedAtFilter.value, + })), }; const attributeFilters: Prisma.MembershipWhereInput["AttributeToUser"][] = filters - .filter((filter) => filter.id !== "role" && filter.id !== "teams") + .filter( + (filter) => + filter.id !== "role" && + filter.id !== "teams" && + filter.id !== "lastActiveAt" && + filter.id !== "createdAt" && + filter.id !== "updatedAt" + ) .map((filter) => { if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) { const attributeOptionValues: string[] = []; @@ -163,6 +194,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { id: true, role: true, accepted: true, + createdAt: true, + updatedAt: true, user: { select: { id: true, @@ -249,6 +282,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { .format(membership.user.lastActiveAt) .toLowerCase() : null, + createdAt: membership.createdAt + ? new Intl.DateTimeFormat(ctx.user.locale, { + timeZone: ctx.user.timeZone, + }) + .format(membership.createdAt) + .toLowerCase() + : null, + updatedAt: membership.updatedAt + ? new Intl.DateTimeFormat(ctx.user.locale, { + timeZone: ctx.user.timeZone, + }) + .format(membership.updatedAt) + .toLowerCase() + : null, avatarUrl: user.avatarUrl, teams: user.teams .filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team