Skip to content

Commit

Permalink
Merge pull request #4689 from bcgov/feat/3974
Browse files Browse the repository at this point in the history
feat(3974): add task page
  • Loading branch information
golebu2020 authored Jan 23, 2025
2 parents 7fe55a2 + eea548a commit 64fe82a
Show file tree
Hide file tree
Showing 22 changed files with 661 additions and 13 deletions.
38 changes: 38 additions & 0 deletions app/app/api/tasks/download/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GlobalPermissions } from '@/constants';
import createApiHandler from '@/core/api-handler';
import { NoContent, CsvResponse } from '@/core/responses';
import { formatFullName } from '@/helpers/user';
import { searchTasks } from '@/services/db';
import { formatDate } from '@/utils/js';
import { taskSearchBodySchema } from '@/validation-schemas/task';

export const POST = createApiHandler({
permissions: [GlobalPermissions.ViewTasks],
validations: { body: taskSearchBodySchema },
})(async ({ session, body }) => {
const searchProps = {
...body,
page: 1,
pageSize: 10000,
};

const { data } = await searchTasks(searchProps);

if (data.length === 0) {
return NoContent();
}

const formattedData = data.map((task) => ({
Type: task.type,
Data: task.data,
Status: task.status,
'Task Created': formatDate(task.createdAt),
ClosedMetaData: task.closedMetadata,
'Completed By Name': formatFullName(task.completedByUser),
'Completed By Email': task.completedByUser?.email,
'Completed At': formatDate(task.completedAt),
'Assigned Roles': task.roles?.join(', '),
'Assigned Permissions': task.permissions?.join(', '),
}));
return CsvResponse(formattedData, 'tasks.csv');
});
13 changes: 13 additions & 0 deletions app/app/api/tasks/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { GlobalPermissions } from '@/constants';
import createApiHandler from '@/core/api-handler';
import { OkResponse } from '@/core/responses';
import { searchTasks } from '@/services/db/task';
import { taskSearchBodySchema } from '@/validation-schemas/task';

export const POST = createApiHandler({
permissions: [GlobalPermissions.ViewTasks],
validations: { body: taskSearchBodySchema },
})(async ({ body }) => {
const result = await searchTasks(body);
return OkResponse(result);
});
3 changes: 2 additions & 1 deletion app/app/api/users/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { z } from 'zod';
import { GlobalPermissions } from '@/constants';
import createApiHandler from '@/core/api-handler';
import { userUpdateBodySchema } from '@/validation-schemas';
import updateOp from '../_operations/update';
import { getPathParamSchema, putPathParamSchema, deletePathParamSchema } from './schema';
import { putPathParamSchema } from './schema';

export const PUT = createApiHandler({
permissions: [GlobalPermissions.EditUsers],
Expand Down
55 changes: 55 additions & 0 deletions app/app/tasks/all/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Button, LoadingOverlay, Box } from '@mantine/core';
import { TaskType } from '@prisma/client';
import { startCase } from 'lodash-es';
import { useSnapshot } from 'valtio';
import FormMultiSelect from '@/components/generic/select/FormMultiSelect';
import { taskTypeNames } from '@/constants/task';
import { pageState } from './state';

const taskTypeOptions = Object.entries(taskTypeNames).map(([key, value]) => ({
value: key,
label: value,
}));

export default function FilterPanel({ isLoading = false }: { isLoading?: boolean }) {
const pageSnapshot = useSnapshot(pageState);

return (
<Box pos="relative">
<LoadingOverlay
visible={isLoading}
zIndex={1000}
overlayProps={{ radius: 'sm', blur: 2 }}
loaderProps={{ color: 'pink', type: 'bars' }}
/>
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-12 md:gap-x-3">
<div className="col-span-12">
<FormMultiSelect
name="tasks"
label="Task Types"
value={pageSnapshot.types ?? []}
data={taskTypeOptions}
onChange={(value) => {
pageState.types = value as TaskType[];
pageState.page = 1;
}}
classNames={{ wrapper: '' }}
/>
<div className="text-right">
<Button
color="primary"
size="compact-md"
className="mt-1"
onClick={() => {
pageState.types = taskTypeOptions.map((option) => option.value as TaskType);
pageState.page = 1;
}}
>
Select All
</Button>
</div>
</div>
</div>
</Box>
);
}
172 changes: 172 additions & 0 deletions app/app/tasks/all/TableBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
'use client';

import { Avatar, Badge, Code, Group, Table, Text } from '@mantine/core';
import { TaskStatus } from '@prisma/client';
import { startCase } from 'lodash-es';
import { useForm } from 'react-hook-form';
import MinistryBadge from '@/components/badges/MinistryBadge';
import { ExtendedTask, statusColorMap, taskTypeNames, UserInfo } from '@/constants/task';
import { formatFullName } from '@/helpers/user';
import { getUserImageData } from '@/helpers/user-image';
import { formatDate } from '@/utils/js';

interface TableProps {
data: ExtendedTask[];
assignees: UserInfo[];
}

export default function TableBody({ data, assignees }: TableProps) {
const methods = useForm({
values: {
tasks: data,
taskAssignees: assignees,
},
});

const [tasks, taskAssignees] = methods.watch(['tasks', 'taskAssignees']);

const rows =
tasks.length && taskAssignees ? (
tasks.map((task) => (
<Table.Tr key={task.id}>
<Table.Td>{taskTypeNames[task.type]}</Table.Td>
<Table.Td>
{task.status === TaskStatus.COMPLETED ? (
<Badge color="green">{task.status}</Badge>
) : (
<Badge color="blue">{task.status}</Badge>
)}
</Table.Td>
<Table.Td>
{task.closedMetadata && Object.keys(task.closedMetadata).length !== 0 && (
<ul>
{Object.entries(task.closedMetadata).map(([key, value]) => (
<li key={key}>
<Badge color={statusColorMap[value] || 'teal'}>
{typeof value === 'object' ? JSON.stringify(value) : value.toString()}
</Badge>
</li>
))}
</ul>
)}
</Table.Td>
<Table.Td>
{task.roles && task.roles?.length !== 0 && (
<>
<Text c="dimmed" size="sm" className="font-semibold">
Roles:
</Text>
<ul className="mb-3">
{task.roles.map((role, index) => (
<li key={index}>
<Badge autoContrast={true} size="sm" color="gray">
{startCase(role)}
</Badge>
</li>
))}
</ul>
</>
)}

{task.permissions?.length > 0 && (
<>
<Text c="dimmed" size="sm" className="font-semibold">
Permissions:
</Text>
<ul>
{task.permissions.map((permission, index) => (
<li key={index}>
<Text size="sm">
<Badge autoContrast={true} size="sm" color="gray">
{startCase(permission)}
</Badge>
</Text>
</li>
))}
</ul>
</>
)}
</Table.Td>
<Table.Td>
{task.userIds.length > 0 && (
<ul>
{task.userIds.map((id, index) => {
const userInfo = taskAssignees[index];
if (!userInfo) return null;
return (
<li className="my-5" key={index}>
<Avatar src={getUserImageData(userInfo.image)} size={36} radius="xl" />
<div>
<Text size="sm" className="font-semibold">
<div>
{formatFullName(userInfo)}
<MinistryBadge className="ml-1" ministry={userInfo.ministry} />
</div>
</Text>
<Text size="xs" opacity={0.5}>
{userInfo.email}
</Text>
</div>
</li>
);
})}
</ul>
)}
</Table.Td>
<Table.Td>
<Code block>{JSON.stringify(task.data, null, 2)}</Code>
</Table.Td>
<Table.Td className="italic">{formatDate(task.createdAt)}</Table.Td>
<Table.Td>
<Group gap="sm" className="cursor-pointer" onClick={async () => {}}>
{task.completedByUser && (
<>
<Avatar src={getUserImageData(task.completedByUser.image)} size={36} radius="xl" />
<div>
<Text size="sm" className="font-semibold">
<div>
{formatFullName(task.completedByUser)}
<MinistryBadge className="ml-1" ministry={task.completedByUser.ministry} />
</div>
</Text>
<Text size="xs" opacity={0.5}>
{task.completedByUser.email}
</Text>
<Text className="mt-2 italic" size="sm">
At: {formatDate(task.completedAt)}
</Text>
</div>
</>
)}
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={8} className="italic">
No events found
</Table.Td>
</Table.Tr>
);

return (
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Decision</Table.Th>
<Table.Th>Assigned Roles/Permissions</Table.Th>
<Table.Th>Assigned User(s)</Table.Th>
<Table.Th>Data</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Completed By</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
5 changes: 5 additions & 0 deletions app/app/tasks/all/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use client';

export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
68 changes: 68 additions & 0 deletions app/app/tasks/all/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { useSnapshot } from 'valtio/react';
import Table from '@/components/generic/table/Table';
import { GlobalPermissions } from '@/constants';
import { taskSorts, ExtendedTask, UserInfo } from '@/constants/task';
import createClientPage from '@/core/client-page';
import { downloadTasks, searchTasks } from '@/services/backend/tasks';
import FilterPanel from './FilterPanel';
import { pageState } from './state';
import TableBody from './TableBody';

const tasksPage = createClientPage({
permissions: [GlobalPermissions.ViewTasks],
fallbackUrl: '/login?callbackUrl=/home',
});

export default tasksPage(() => {
const snap = useSnapshot(pageState);
let totalCount = 0;
let tasks: ExtendedTask[] = [];
let usersWithAssignedTasks: UserInfo[] = [];

const { data, isLoading } = useQuery({
queryKey: ['tasks', snap],
queryFn: () => searchTasks(snap),
});

if (!isLoading && data) {
tasks = data.data;
totalCount = data.totalCount;
usersWithAssignedTasks = data.usersWithAssignedTasks;
}

return (
<>
<Table
title="Tasks in Registry"
totalCount={totalCount}
page={snap.page ?? 1}
pageSize={snap.pageSize ?? 10}
sortKey={snap.sortValue}
onPagination={(page: number, pageSize: number) => {
pageState.page = page;
pageState.pageSize = pageSize;
}}
onSearch={(searchTerm: string) => {
pageState.page = 1;
pageState.search = searchTerm;
}}
onExport={async () => {
const result = await downloadTasks(snap);
return result;
}}
onSort={(sortValue) => {
pageState.page = 1;
pageState.sortValue = sortValue;
}}
sortOptions={taskSorts.map((val) => val.label)}
filters={<FilterPanel />}
isLoading={isLoading}
>
<TableBody data={tasks} assignees={usersWithAssignedTasks} />
</Table>
</>
);
});
16 changes: 16 additions & 0 deletions app/app/tasks/all/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { proxy } from 'valtio';
import { deepClone } from 'valtio/utils';
import { taskSorts } from '@/constants/task';
import { TaskSearchBody } from '@/validation-schemas/task';

const initialValue = {
page: 1,
pageSize: 10,
search: '',
types: [],
sortValue: taskSorts[0].label,
sortKey: taskSorts[0].sortKey,
sortOrder: taskSorts[0].sortOrder,
};

export const pageState = proxy<TaskSearchBody>(deepClone(initialValue));
Loading

0 comments on commit 64fe82a

Please sign in to comment.