Skip to content

Commit ede5cc1

Browse files
committed
feat: case manager v3 - new inboxes page
1 parent d000757 commit ede5cc1

33 files changed

Lines changed: 1431 additions & 562 deletions

packages/app-builder/src/components/Cases/CasesList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SelectionProps } from '@app-builder/hooks/useListSelection';
1+
import { SelectionProps } from '@app-builder/hooks/useTanstackTableListSelection';
22
import { type Case } from '@app-builder/models/cases';
33
import { useOrganizationTags } from '@app-builder/services/organization/organization-tags';
44
import {

packages/app-builder/src/components/Cases/CreateCase.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { useGetInboxesQuery } from '@app-builder/queries/cases/get-inboxes';
1212
import { getFieldErrors } from '@app-builder/utils/form';
1313
import { useForm } from '@tanstack/react-form';
14+
import { useQueryClient } from '@tanstack/react-query';
1415
import { useTranslation } from 'react-i18next';
1516
import { Button, Select } from 'ui-design-system';
1617
import { Icon } from 'ui-icons';
@@ -21,6 +22,7 @@ export function CreateCase() {
2122
const createCaseMutation = useCreateCaseMutation();
2223
const { data } = useCaseRightPanelContext();
2324
const revalidate = useLoaderRevalidator();
25+
const queryClient = useQueryClient();
2426

2527
const form = useForm({
2628
defaultValues: {
@@ -30,6 +32,7 @@ export function CreateCase() {
3032
onSubmit: ({ value, formApi }) => {
3133
if (formApi.state.isValid) {
3234
createCaseMutation.mutateAsync(value).then((res) => {
35+
queryClient.invalidateQueries({ queryKey: ['cases', 'get-cases', value.inboxId] });
3336
revalidate();
3437
});
3538
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { User } from '@app-builder/models';
2+
import { CaseContributor } from '@app-builder/models/cases';
3+
import { useOrganizationUsers } from '@app-builder/services/organization/organization-users';
4+
import { getFullName } from '@app-builder/services/user';
5+
import { useTranslation } from 'react-i18next';
6+
import { Avatar, cn, Tooltip } from 'ui-design-system';
7+
8+
export const AssignedContributors = ({
9+
assignedTo,
10+
contributors,
11+
}: {
12+
assignedTo: string | undefined;
13+
contributors: CaseContributor[];
14+
}) => {
15+
const { getOrgUserById } = useOrganizationUsers();
16+
const assignedUser = assignedTo ? getOrgUserById(assignedTo) : undefined;
17+
const contributorsUsers = contributors.map((contributor) => getOrgUserById(contributor.userId));
18+
19+
return (
20+
<div className="inline-flex items-center gap-v2-sm">
21+
{assignedTo ? <AvatarWithTooltip user={assignedUser} className="border-purple-65" /> : null}
22+
<span className="lg:flex items-center gap-v2-xs group/contributors hidden">
23+
{contributorsUsers.map((user, idx) =>
24+
user ? (
25+
<div
26+
key={user.userId}
27+
className="w-4 group-hover/contributors:w-9 rotate-0 overflow-visible transition-all"
28+
>
29+
<AvatarWithTooltip user={user} />
30+
</div>
31+
) : null,
32+
)}
33+
</span>
34+
</div>
35+
);
36+
};
37+
38+
type AvatarWithTooltipProps = {
39+
user: User | undefined;
40+
className?: string;
41+
};
42+
43+
export const AvatarWithTooltip = ({ user, className }: AvatarWithTooltipProps) => {
44+
const { t } = useTranslation(['cases']);
45+
const avatar = (
46+
<Avatar key={user?.userId} size="s" firstName={user?.firstName} lastName={user?.lastName} />
47+
);
48+
49+
return (
50+
<Tooltip.Default
51+
content={
52+
<div key={user?.userId ?? 0} className="flex flex-row items-center gap-1">
53+
{avatar}
54+
<div className="text-grey-00 text-xs font-normal capitalize">
55+
{getFullName(user) || t('cases:case_detail.unknown_user')}
56+
</div>
57+
</div>
58+
}
59+
>
60+
<div className="flex w-fit flex-row items-center gap-1">
61+
<span className={cn('border-2 border-white rounded-full', className)}>{avatar}</span>
62+
</div>
63+
</Tooltip.Default>
64+
);
65+
};
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { MultiSelect } from '@app-builder/components/MultiSelect';
2+
import { useOrganizationTags } from '@app-builder/services/organization/organization-tags';
3+
import { formatDateRelative } from '@app-builder/utils/format';
4+
import { getRoute } from '@app-builder/utils/routes';
5+
import { fromUUIDtoSUUID } from '@app-builder/utils/short-uuid';
6+
import { Link } from '@remix-run/react';
7+
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { Checkbox, cn } from 'ui-design-system';
10+
import { Icon } from 'ui-icons';
11+
import { CaseStatusBadge } from '../CaseStatus';
12+
import { AssignedContributors } from './AssignedContributors';
13+
import { PaginationRow, SuccessCasesQuery } from './PaginationRow';
14+
15+
export type CasesListProps = {
16+
casesQuery: SuccessCasesQuery;
17+
sorting: 'ASC' | 'DESC';
18+
onSortingChange: (sort: 'ASC' | 'DESC') => void;
19+
limit: number;
20+
setLimit: (limit: number) => void;
21+
isPaginationSticky: boolean;
22+
};
23+
24+
const handleRowClick: MouseEventHandler = (e) => {
25+
const rowLink = e.currentTarget.querySelector('[data-row-link]');
26+
if (rowLink && rowLink !== e.target && rowLink instanceof HTMLAnchorElement) {
27+
rowLink.dispatchEvent(new MouseEvent(e.type, e.nativeEvent));
28+
}
29+
};
30+
31+
export function CasesList({
32+
sorting,
33+
onSortingChange,
34+
casesQuery,
35+
limit,
36+
setLimit,
37+
isPaginationSticky,
38+
}: CasesListProps) {
39+
const {
40+
t,
41+
i18n: { language },
42+
} = useTranslation(['cases']);
43+
const lastPageRef = useRef<number>(0);
44+
const [currentPage, setCurrentPage] = useState(0);
45+
const cases =
46+
casesQuery.data?.pages[currentPage]?.items ??
47+
casesQuery.data?.pages[lastPageRef.current]?.items ??
48+
[];
49+
const { orgTags } = useOrganizationTags();
50+
51+
useEffect(() => {
52+
if (casesQuery.data?.pages[currentPage]?.items) {
53+
lastPageRef.current = currentPage;
54+
}
55+
}, [casesQuery.data?.pages[currentPage]?.items]);
56+
57+
return (
58+
<div className="flex flex-col">
59+
<div className="w-full grid grid-cols-[0px_auto_1fr_auto_auto_auto_auto] border border-grey-border rounded-v2-md">
60+
<div className="grid grid-cols-subgrid col-span-full items-center group/table-row not-last:border-b border-grey-border">
61+
<HeaderCell className="ps-v2-xl relative col-span-2">
62+
<MultiSelect.Global>
63+
{(state, onSelect) => (
64+
<Checkbox
65+
checked={state}
66+
onClick={onSelect}
67+
className="absolute left-0 top-[50%] translate-[-50%] opacity-0 group-hover/table-row:opacity-100 data-[state=checked]:opacity-100 data-[state=indeterminate]:opacity-100"
68+
/>
69+
)}
70+
</MultiSelect.Global>
71+
Status
72+
</HeaderCell>
73+
<HeaderCell>Name of the case</HeaderCell>
74+
<HeaderCell>Review status</HeaderCell>
75+
<HeaderCell className="flex items-center gap-v2-sm justify-between">
76+
Date
77+
<Icon
78+
icon="caret-down"
79+
className={cn('size-5 cursor-pointer', {
80+
'rotate-180': sorting === 'ASC',
81+
})}
82+
onClick={() => onSortingChange(sorting === 'ASC' ? 'DESC' : 'ASC')}
83+
/>
84+
</HeaderCell>
85+
<HeaderCell>Tags</HeaderCell>
86+
<HeaderCell>
87+
<span className="hidden lg:inline">Assigned & Contributors</span>
88+
<span className="lg:hidden">Assign.</span>
89+
</HeaderCell>
90+
</div>
91+
{cases.map((caseItem) => (
92+
<div
93+
className="grid grid-cols-subgrid col-span-full items-center group/table-row hover:bg-purple-98 cursor-pointer"
94+
key={caseItem.id}
95+
onClick={handleRowClick}
96+
>
97+
<div className="invisible">
98+
<Link
99+
data-row-link
100+
to={getRoute('/cases/:caseId', { caseId: fromUUIDtoSUUID(caseItem.id) })}
101+
/>
102+
</div>
103+
<div className="relative p-v2-md ps-v2-xl w-25">
104+
<MultiSelect.Item id={caseItem.id}>
105+
{(isSelected, onSelect) => (
106+
<Checkbox
107+
// checked={selectedRows.includes(caseItem.id)}
108+
// data-row-id={caseItem.id}
109+
// onClick={handleCheckboxClick}
110+
checked={isSelected}
111+
onClick={onSelect}
112+
className="absolute left-0 top-[50%] translate-[-50%] opacity-0 group-hover/table-row:opacity-100 data-[state=checked]:opacity-100"
113+
/>
114+
)}
115+
</MultiSelect.Item>
116+
<CaseStatusBadge status={caseItem.status} size="large" showText={false} />
117+
</div>
118+
<div className="p-v2-md group-hover/table-row:text-purple-65 group-hover/table-row:underline">
119+
{caseItem.name}
120+
</div>
121+
<div className="p-v2-md">
122+
{caseItem.outcome && caseItem.outcome !== 'unset' ? (
123+
<span
124+
className={cn('rounded-full border px-v2-sm py-v2-xs text-small', {
125+
'border-red-47 text-red-47': caseItem.outcome === 'confirmed_risk',
126+
'border-green-38 text-green-38': caseItem.outcome === 'valuable_alert',
127+
'border-grey-50 text-grey-50': caseItem.outcome === 'false_positive',
128+
})}
129+
>
130+
{t(`cases:case.outcome.${caseItem.outcome}`)}
131+
</span>
132+
) : (
133+
'-'
134+
)}
135+
</div>
136+
<div className="p-v2-md">{formatDateRelative(caseItem.createdAt, { language })}</div>
137+
<div className="p-v2-md flex gap-v2-sm">
138+
{caseItem.tags.map((tagItem) => {
139+
const tag = orgTags.find((tag) => tag.id === tagItem.tagId);
140+
if (!tag) return null;
141+
return <TagPreview key={tag.id} name={tag.name} />;
142+
})}
143+
</div>
144+
<div className="p-v2-md">
145+
<AssignedContributors
146+
assignedTo={caseItem.assignedTo}
147+
contributors={caseItem.contributors}
148+
/>
149+
</div>
150+
</div>
151+
))}
152+
</div>
153+
<PaginationRow
154+
casesQuery={casesQuery}
155+
currentPage={currentPage}
156+
currentLimit={limit}
157+
setCurrentPage={setCurrentPage}
158+
setLimit={setLimit}
159+
className={isPaginationSticky ? 'shadow-sticky-bottom border-grey-border' : ''}
160+
/>
161+
</div>
162+
);
163+
}
164+
165+
const HeaderCell = ({ children, className }: { children: React.ReactNode; className?: string }) => {
166+
return (
167+
<div
168+
className={cn(
169+
'p-v2-md font-normal text-left not-first:border-l border-grey-border',
170+
className,
171+
)}
172+
>
173+
{children}
174+
</div>
175+
);
176+
};
177+
178+
const TagPreview = ({ name }: { name: string }) => (
179+
<div className="bg-purple-96 flex size-fit flex-row items-center gap-2 rounded-full px-2 py-[3px]">
180+
<span className="text-purple-65 text-xs font-normal">{name}</span>
181+
</div>
182+
);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { QueryEntry } from '@app-builder/hooks/useBase64Query';
2+
import type { filtersSchema } from '@app-builder/queries/cases/get-cases';
3+
import { useOrganizationUsers } from '@app-builder/services/organization/organization-users';
4+
import { formatDateTimeWithoutPresets } from '@app-builder/utils/format';
5+
import { differenceInDays } from 'date-fns';
6+
import { useTranslation } from 'react-i18next';
7+
import * as R from 'remeda';
8+
import { match, P } from 'ts-pattern';
9+
import { Avatar } from 'ui-design-system';
10+
import { Icon } from 'ui-icons';
11+
import { InboxFilterLabel } from './FilterLabel';
12+
13+
type ActivatedFilterItemProps = {
14+
filter: QueryEntry<typeof filtersSchema>;
15+
onClear: () => void;
16+
};
17+
18+
export const ActivatedFilterItem = ({ filter, onClear }: ActivatedFilterItemProps) => {
19+
return (
20+
<span className="h-10 bg-purple-98 border border-purple-96 rounded-v2-md p-v2-sm text-default flex items-center gap-v2-xs">
21+
<span>
22+
<DisplayFilterValue filter={filter} />
23+
</span>
24+
<button type="button" onClick={onClear} className="cursor-pointer">
25+
<Icon icon="cross" className="size-4" />
26+
</button>
27+
</span>
28+
);
29+
};
30+
31+
type DisplayFilterValueProps = { filter: QueryEntry<typeof filtersSchema> };
32+
33+
const DisplayFilterValue = ({ filter }: DisplayFilterValueProps) => {
34+
const {
35+
t,
36+
i18n: { language },
37+
} = useTranslation(['filters', 'cases']);
38+
39+
return match(filter)
40+
.with(['name', P.string], ([name, value]) => (
41+
<span>
42+
<InboxFilterLabel name={name} />: {value}
43+
</span>
44+
))
45+
.with(['statuses', P.array(P.string)], ([name, value]) => (
46+
<span>
47+
<InboxFilterLabel name={name} />
48+
</span>
49+
))
50+
.with(['includeSnoozed', P.boolean], ([name]) => (
51+
<span>
52+
<InboxFilterLabel name={name} />
53+
</span>
54+
))
55+
.with(['excludeAssigned', P.boolean], ([name]) => (
56+
<span>
57+
<InboxFilterLabel name={name} />
58+
</span>
59+
))
60+
.with(['assignee', P.string], ([name, value]) => (
61+
<span>
62+
<InboxFilterLabel name={name} />: <AssigneeFilterValue value={value} />
63+
</span>
64+
))
65+
.with(['dateRange', P.shape({ startDate: P.string, endDate: P.string })], ([name, value]) => {
66+
const startDate = formatDateTimeWithoutPresets(value.startDate, { language });
67+
const endDate = formatDateTimeWithoutPresets(value.endDate, { language });
68+
const diff = differenceInDays(new Date(value.endDate), new Date(value.startDate));
69+
const dateDisplay =
70+
diff <= 1 ? startDate : t('filters:date_range.range_value', { startDate, endDate });
71+
72+
return (
73+
<span>
74+
<InboxFilterLabel name={name} />: {dateDisplay}
75+
</span>
76+
);
77+
})
78+
.exhaustive();
79+
};
80+
81+
const AssigneeFilterValue = ({ value }: { value: string }) => {
82+
const { t } = useTranslation(['cases']);
83+
const { orgUsers } = useOrganizationUsers();
84+
85+
const user = orgUsers.find((user) => user.userId === value);
86+
87+
return (
88+
<span className="inline-flex items-center gap-v2-xs">
89+
<Avatar size="xs" firstName={user?.firstName} lastName={user?.lastName} />
90+
{user ? (
91+
<span>{`${R.capitalize(user.firstName)} ${R.capitalize(user.lastName)}`}</span>
92+
) : (
93+
<span>{t('cases:case_detail.unknown_user')}</span>
94+
)}
95+
</span>
96+
);
97+
};

0 commit comments

Comments
 (0)