diff --git a/src/app/portal/activities/_components/ActivityDetails/ActivityInfoBody.tsx b/src/app/portal/activities/_components/ActivityDetails/ActivityInfoBody.tsx
index 0d33c4ad..44a0b28a 100644
--- a/src/app/portal/activities/_components/ActivityDetails/ActivityInfoBody.tsx
+++ b/src/app/portal/activities/_components/ActivityDetails/ActivityInfoBody.tsx
@@ -18,14 +18,14 @@ import { ActivityDetailsProps } from '@/libs/supabase/api/_response';
import { getAssignedFaculties } from '@/libs/supabase/api/faculty-assignments';
import { getActivityReports } from '@/libs/supabase/api/storage';
import {
- IconFileText,
+ IconLibrary,
IconRosetteDiscountCheck,
IconScanEye,
IconUsersGroup,
} from '@tabler/icons-react';
import { downloadActivityFile } from '@portal/activities/actions';
import dayjs from '@/libs/dayjs';
-import { isElevated, isInternal } from '@/utils/access-control';
+import { isPrivate, isInternal } from '@/utils/access-control';
import { identifyFileType } from '@/utils/file-types';
const RTEditor = dynamic(
@@ -167,7 +167,7 @@ function ActivityDetailsBody({
<>
- {isElevated(role) && (
+ {isPrivate(role) && (
<>
- {isElevated(role) && files && (
+ {isInternal(role) && files && (
<>
-
+
Reports
}
@@ -263,51 +263,45 @@ function ActivityDetailsBody({
{files.length > 0 ? (
<>
{files.map((file) => (
- <>
-
-
- {identifyFileType(file.type)}
-
+
+
+ {identifyFileType(file.type)}
+
-
-
saveFile(file.name, file.checksum)}
- size="sm"
- ta="left"
- >
- {file.name}
-
-
-
- {dayjs(file.uploaded_at).fromNow()}
-
+
+
saveFile(file.name, file.checksum)}
+ size="sm"
+ ta="left"
+ >
+ {file.name}
+
+
+
+ {dayjs(file.uploaded_at).fromNow()}
+
-
-
- }
- onClick={() => clipboard.copy(file.checksum)}
- size="xs"
- variant="transparent"
- >
- {file.checksum.slice(0, 8)}
-
-
-
-
-
- >
+
+ }
+ onClick={() => clipboard.copy(file.checksum)}
+ size="xs"
+ variant="transparent"
+ >
+ {file.checksum.slice(0, 8)}
+
+
+
+
+
))}
>
) : (
diff --git a/src/app/portal/activities/_components/ActivityDetails/ActivityInfoHeader.tsx b/src/app/portal/activities/_components/ActivityDetails/ActivityInfoHeader.tsx
index 1868b780..f7883aec 100644
--- a/src/app/portal/activities/_components/ActivityDetails/ActivityInfoHeader.tsx
+++ b/src/app/portal/activities/_components/ActivityDetails/ActivityInfoHeader.tsx
@@ -27,6 +27,7 @@ import {
IconRss,
IconTrash,
IconUpload,
+ IconUsersGroup,
} from '@tabler/icons-react';
import { useProgress } from 'react-transition-progress';
import { formatDateRange } from 'little-date';
@@ -40,8 +41,9 @@ import {
} from '@portal/activities/actions';
import { ActivityFormProps } from '../Forms/ActivityFormModal';
import type { Enums } from '@/libs/supabase/_database';
-import { isInternal } from '@/utils/access-control';
+import { isElevated, isInternal, isStudent } from '@/utils/access-control';
import { useUser } from '@/components/Providers/UserProvider';
+import { FacultyAssignmentModal } from '../Forms/FacultyAssignmentModal';
const ActivityFormModal = dynamic(
() =>
@@ -138,7 +140,10 @@ function ActivityDetailsHeader({
}) {
const { id: userId } = useUser();
- const [opened, { open, close }] = useDisclosure(false);
+ const [editOpened, { open: editOpen, close: editClose }] =
+ useDisclosure(false);
+ const [assignOpened, { open: assignOpen, close: assignClose }] =
+ useDisclosure(false);
const [localFiles, setLocalFiles] = useState();
const [subscribed, setSubscribed] = useState(false);
@@ -188,12 +193,16 @@ function ActivityDetailsHeader({
<>
+
-
+
{activity?.title}
@@ -235,81 +244,94 @@ function ActivityDetailsHeader({
{/* Activity control buttons */}
- {isInternal(role) ? (
- <>
-
+ <>
+
+ {isStudent(role) && (
}
- onClick={open}
- variant="default"
+ leftSection={}
+ onClick={() =>
+ onUserSubscribe(
+ activity.id as string,
+ userId,
+ subscribed ? !subscribed : true,
+ setSubscribed,
+ )
+ }
+ variant={subscribed ? 'default' : 'filled'}
>
- Adjust Details
+ {subscribed ? 'Unsubscribe' : 'Subscribe'}
+ )}
+ {/* Internal-only controls */}
+ {isInternal(role) && (
+ <>
+ }
+ onClick={editOpen}
+ variant="default"
+ >
+ Adjust Details
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={toggleEdit}
+ variant="default"
+ >
+ {editable ? 'Hide Toolbars' : 'Edit Description'}
+
+ >
+ )}
+ {/* Faculty Assignment */}
+ {isElevated(role) && (
- ) : (
-
- )
- }
- onClick={toggleEdit}
+ leftSection={}
+ onClick={assignOpen}
variant="default"
>
- {editable ? 'Hide Toolbars' : 'Edit Description'}
+ Assign Faculty
-
+ )}
+
-
+
-
- {(props) => (
-
- }
- variant="default"
- {...props}
- >
- Upload Reports
-
-
- )}
-
+
+ {(props) => (
+
+ }
+ variant="default"
+ {...props}
+ >
+ Upload Reports
+
+
+ )}
+
- }
- onClick={() =>
- deleteModal(activity.id as string, router, startProgress)
- }
- variant="filled"
- >
- Delete Activity
-
- >
- ) : (
- <>
- }
- onClick={() =>
- onUserSubscribe(
- activity.id as string,
- userId,
- subscribed ? !subscribed : true,
- setSubscribed,
- )
- }
- variant={subscribed ? 'default' : 'filled'}
- >
- {subscribed ? 'Unsubscribe' : 'Subscribe'}
-
- >
- )}
+ }
+ onClick={() =>
+ deleteModal(activity.id as string, router, startProgress)
+ }
+ variant="outline"
+ >
+ Delete Activity
+
+ >
- import('./FacultyList').then((mod) => ({
- default: mod.FacultyList,
- })),
- {
- loading: () => ,
- ssr: false,
- },
-);
-
export interface ActivityFormProps {
id?: string;
title: string;
@@ -84,7 +70,6 @@ export function ActivityFormModalComponent({
Tables<'activities_details_view'>[]
>([]);
const [original, setOriginal] = useState();
- const [isInternal, setIsInternal] = useState(false);
// image file preview state
const [coverFile, setCoverFile] = useState([]);
@@ -120,13 +105,6 @@ export function ActivityFormModalComponent({
},
onValuesChange: async (values) => {
- // check if visibility is set to internal
- if (values.visibility === 'Internal') {
- setIsInternal(true);
- } else {
- setIsInternal(false);
- }
-
// clear end date if start date is empty
if (!values.date_starting) {
form.setFieldValue('date_ending', null);
@@ -209,7 +187,13 @@ export function ActivityFormModalComponent({
}, [activity]);
return (
-
+
diff --git a/src/app/portal/activities/_components/Forms/FacultyAssignmentModal.tsx b/src/app/portal/activities/_components/Forms/FacultyAssignmentModal.tsx
new file mode 100644
index 00000000..89464dcf
--- /dev/null
+++ b/src/app/portal/activities/_components/Forms/FacultyAssignmentModal.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { memo, useEffect, useState } from 'react';
+import dynamic from 'next/dynamic';
+import { Button, Checkbox, Group, Modal } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { notifications } from '@mantine/notifications';
+import { IconArrowRight } from '@tabler/icons-react';
+import { PageLoader } from '@/components/Loader/PageLoader';
+import { ActivityFormProps } from './ActivityFormModal';
+import { assignFaculty } from '../../actions';
+
+interface Props {
+ handled_by: string[];
+}
+
+const FacultyList = dynamic(
+ () =>
+ import('./FacultyList').then((mod) => ({
+ default: mod.FacultyList,
+ })),
+ {
+ loading: () => ,
+ ssr: false,
+ },
+);
+
+/**
+ * Modal form for creating or updating an activity.
+ *
+ * @param activity - The activity data to edit/update (optional).
+ * @param opened - The state of the modal.
+ * @param close - The function to close the modal.
+ */
+export function FacultyAssignment({
+ activity,
+ opened,
+ close,
+}: {
+ activity: ActivityFormProps;
+ opened: boolean;
+ close: () => void;
+}) {
+ const [original, setOriginal] = useState();
+ const [pending, setPending] = useState(false);
+
+ // form submission
+ const form = useForm({
+ mode: 'uncontrolled',
+ validateInputOnChange: true,
+
+ initialValues: {
+ handled_by: [],
+ },
+ });
+
+ // form handler & submission
+ const handleSubmit = async (values: Props) => {
+ setPending(true);
+ // todo - submit faculty assignment
+ const result = await assignFaculty(
+ activity.id!,
+ values.handled_by,
+ original,
+ );
+ setPending(false);
+
+ // only show error notification, if any
+ if (result?.status !== 0) {
+ notifications.show({
+ title: result?.title,
+ message: result?.message,
+ color: result?.status === 1 ? 'yellow' : 'red',
+ withBorder: true,
+ withCloseButton: true,
+ autoClose: 8000,
+ });
+ } else {
+ notifications.show({
+ title: result?.title,
+ message: result?.message,
+ color: 'green',
+ withBorder: true,
+ withCloseButton: true,
+ autoClose: 4000,
+ });
+ }
+
+ form.reset();
+ close();
+ };
+
+ useEffect(() => {
+ if (activity) {
+ // keep record of the original activity data
+ setOriginal(activity.handled_by ?? []);
+
+ form.setValues({
+ handled_by: activity.handled_by,
+ });
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activity]);
+
+ return (
+
+
+
+ );
+}
+
+export const FacultyAssignmentModal = memo(FacultyAssignment);
diff --git a/src/app/portal/activities/_components/Forms/FacultyList.tsx b/src/app/portal/activities/_components/Forms/FacultyList.tsx
index f3a0f600..8b6c76b8 100644
--- a/src/app/portal/activities/_components/Forms/FacultyList.tsx
+++ b/src/app/portal/activities/_components/Forms/FacultyList.tsx
@@ -174,16 +174,14 @@ export function FacultyListComponent({
{item.department}
-
+
}>
{assignments.includes(item.id) ? (
-
- Busy
+
+ Assigned
) : (
-
- Available
-
+ Unassigned
)}
@@ -192,7 +190,7 @@ export function FacultyListComponent({
});
return (
-
+
Name
Department
- Availability
+ Assignment
diff --git a/src/app/portal/activities/actions.ts b/src/app/portal/activities/actions.ts
index bd55f000..47b2d798 100644
--- a/src/app/portal/activities/actions.ts
+++ b/src/app/portal/activities/actions.ts
@@ -137,69 +137,20 @@ export async function submitActivity(
};
}
} else {
- // save assigned faculty
- if (activity.handled_by?.length) {
- const assignResponse = await postFacultyAssignment({
- userId: activity.handled_by,
- activityId,
- supabase,
+ // schedule (delay) email reminders
+ if (existingId && activity.date_starting) {
+ rescheduleReminders({
+ activityId: activityId,
+ activityStartingDate: activity.date_starting,
+ });
+ } else {
+ scheduleReminders({
+ activityId: activityId,
+ activityStartingDate: activity.date_starting!,
});
-
- // check if there are changes in assignments
- if (
- existingId &&
- (original?.handled_by !== activity.handled_by ||
- activity.handled_by.length)
- ) {
- // remove unassigned faculties
- await deleteFacultyAssignment({
- userId: activity.handled_by.filter(
- (id) => !original?.handled_by?.includes(id),
- ),
- activityId,
- supabase,
- });
-
- // send email notice to unassigned faculties
- emailUnassigned.trigger({
- activity: activity.title,
- ids: activity.handled_by.filter(
- (id) => !original?.handled_by?.includes(id),
- ),
- });
- // send email notice to newly assigned faculties
- emailAssigned.trigger({
- activity: activity.title,
- ids: activity.handled_by.filter(
- (id) => !original?.handled_by?.includes(id),
- ),
- });
- } else {
- // send email notice to assign faculties
- emailAssigned.trigger({
- activity: activity.title,
- ids: activity.handled_by,
- });
- }
- if (!assignResponse.data) return assignResponse;
}
}
- // schedule (delay) email reminders
- if (existingId && activity.date_starting) {
- rescheduleReminders({
- activityId: activityId,
- activityTitle: activity.title,
- activityStartingDate: activity.date_starting,
- });
- } else {
- scheduleReminders({
- activityId: activityId,
- activityTitle: activity.title,
- activityStartingDate: activity.date_starting!,
- });
- }
-
if (existingId) {
return {
status: 0,
@@ -215,6 +166,62 @@ export async function submitActivity(
}
}
+/**
+ * Assign faculty to an activity.
+ *
+ * @param faculty - The faculty ids to assign.
+ * @param activityId - The activity id to assign.
+ */
+export async function assignFaculty(
+ activityId: string,
+ faculty: string[],
+ original?: string[] | null,
+): Promise {
+ const cookieStore = cookies();
+ const supabase = await createServerClient(cookieStore);
+
+ const assignResponse = await postFacultyAssignment({
+ userId: faculty,
+ activityId,
+ supabase,
+ });
+
+ // check if there are changes in assignments
+ if (original !== faculty || faculty.length) {
+ // remove unassigned faculties
+ await deleteFacultyAssignment({
+ userId: faculty.filter((id) => !original?.includes(id)),
+ activityId,
+ supabase,
+ });
+
+ // send email notice to unassigned faculties
+ emailUnassigned.trigger({
+ activityId: activityId,
+ ids: faculty.filter((id) => !original?.includes(id)),
+ });
+ // send email notice to newly assigned faculties
+ emailAssigned.trigger({
+ activityId: activityId,
+ ids: faculty.filter((id) => !original?.includes(id)),
+ });
+ } else {
+ // send email notice to assign faculties
+ emailAssigned.trigger({
+ activityId: activityId,
+ ids: faculty,
+ });
+ }
+
+ if (!assignResponse.data) return assignResponse;
+
+ return {
+ status: 0,
+ title: 'Faculty assigned',
+ message: 'Faculty has been successfully assigned.',
+ };
+}
+
/**
* Delete an activity.
*
diff --git a/src/libs/triggerdev/reminders.ts b/src/libs/triggerdev/reminders.ts
index 8aeb9520..4bcd547b 100644
--- a/src/libs/triggerdev/reminders.ts
+++ b/src/libs/triggerdev/reminders.ts
@@ -12,11 +12,9 @@ import { emailReminders } from '@/trigger/email-reminders';
*/
export async function scheduleReminders({
activityId,
- activityTitle,
activityStartingDate,
}: {
activityId: string;
- activityTitle: string;
activityStartingDate: Date;
}) {
// check if the activity starting date is not within 1 day from now
@@ -24,10 +22,7 @@ export async function scheduleReminders({
if (activityStartingDate.getTime() - 1 * 24 * 60 * 60 * 1000 > Date.now()) {
// schedule new reminder task, 1 day before the activity
await emailReminders.trigger(
- {
- activityId: activityId,
- activityTitle: activityTitle,
- },
+ { activityId: activityId },
{
idempotencyKey: activityId + '_1d',
tags: [`activity_${activityId}`, 'action_reminders', 'in_1'],
@@ -43,10 +38,7 @@ export async function scheduleReminders({
if (activityStartingDate.getTime() - 3 * 24 * 60 * 60 * 1000 > Date.now()) {
// schedule new reminder task, 3 and 7 days before the activity
await emailReminders.trigger(
- {
- activityId: activityId,
- activityTitle: activityTitle,
- },
+ { activityId: activityId },
{
idempotencyKey: activityId + '_3d',
tags: [`activity_${activityId}`, 'action_reminders', 'in_3'],
@@ -62,10 +54,7 @@ export async function scheduleReminders({
if (activityStartingDate.getTime() - 7 * 24 * 60 * 60 * 1000 > Date.now()) {
// schedule new reminder task, 7 days before the activity
await emailReminders.trigger(
- {
- activityId: activityId,
- activityTitle: activityTitle,
- },
+ { activityId: activityId },
{
idempotencyKey: activityId + '_7d',
tags: [`activity_${activityId}`, 'action_reminders', 'in_7'],
@@ -89,11 +78,9 @@ export async function scheduleReminders({
*/
export async function rescheduleReminders({
activityId,
- activityTitle,
activityStartingDate,
}: {
activityId: string;
- activityTitle: string;
activityStartingDate: Date;
}) {
// get the queued reminder task
@@ -143,7 +130,6 @@ export async function rescheduleReminders({
// schedule new reminder task
await scheduleReminders({
activityId: activityId,
- activityTitle: activityTitle,
activityStartingDate: activityStartingDate,
});
}
diff --git a/src/trigger/email-assigned.ts b/src/trigger/email-assigned.ts
index abce0baf..7f30f5a3 100644
--- a/src/trigger/email-assigned.ts
+++ b/src/trigger/email-assigned.ts
@@ -13,7 +13,7 @@ export const emailAssigned = task({
id: 'email-assigned',
run: async (
payload: {
- activity: string;
+ activityId: string;
ids: string[];
},
{ ctx },
@@ -35,7 +35,7 @@ export const emailAssigned = task({
const activityQuery = supabase
.from('activities')
.select()
- .eq('title', payload.activity)
+ .eq('id', payload.activityId)
.limit(1)
.single();
diff --git a/src/trigger/email-reminders.ts b/src/trigger/email-reminders.ts
index f40f498e..13a7af8f 100644
--- a/src/trigger/email-reminders.ts
+++ b/src/trigger/email-reminders.ts
@@ -11,10 +11,7 @@ import { createAdminClient } from '@/libs/supabase/admin-client';
*/
export const emailReminders = task({
id: 'email-reminders',
- run: async (
- payload: { activityId: string; activityTitle: string },
- { ctx },
- ) => {
+ run: async (payload: { activityId: string }, { ctx }) => {
await envvars.retrieve('SUPABASE_URL');
await envvars.retrieve('SUPABASE_SERVICE_KEY');
@@ -50,7 +47,7 @@ export const emailReminders = task({
const activityQuery = supabase
.from('activities')
.select()
- .eq('title', payload.activityTitle)
+ .eq('id', payload.activityId)
.limit(1)
.single();
diff --git a/src/trigger/email-unassigned.ts b/src/trigger/email-unassigned.ts
index 70e25660..eee782b0 100644
--- a/src/trigger/email-unassigned.ts
+++ b/src/trigger/email-unassigned.ts
@@ -13,7 +13,7 @@ export const emailUnassigned = task({
id: 'email-unassigned',
run: async (
payload: {
- activity: string;
+ activityId: string;
ids: string[];
},
{ ctx },
@@ -35,7 +35,7 @@ export const emailUnassigned = task({
const activityQuery = supabase
.from('activities')
.select()
- .eq('title', payload.activity)
+ .eq('id', payload.activityId)
.limit(1)
.single();
diff --git a/src/utils/access-control.ts b/src/utils/access-control.ts
index c020a507..bc3058d1 100644
--- a/src/utils/access-control.ts
+++ b/src/utils/access-control.ts
@@ -13,12 +13,28 @@ export const isInternal = (role: Enums<'roles_user'> | null): boolean => {
return role === 'admin' || role === 'staff';
};
+/**
+ * Check if the user is internal or faculty chair.
+ *
+ * @param role - The user's role.
+ */
+export const isElevated = (
+ role: Enums<'roles_user'> | null,
+ pos?: Enums<'roles_pos'> | null,
+): boolean => {
+ if (!role) {
+ return false;
+ }
+
+ return pos?.includes('chair') || isInternal(role);
+};
+
/**
* Check if the user is internal or faculty.
*
* @param role - The user's role.
*/
-export const isElevated = (role: Enums<'roles_user'> | null): boolean => {
+export const isPrivate = (role: Enums<'roles_user'> | null): boolean => {
if (!role) {
return false;
}
@@ -39,6 +55,19 @@ export const isAdmin = (role: Enums<'roles_user'> | null): boolean => {
return role === 'admin';
};
+/**
+ * Check if the user is a student.
+ *
+ * @param role - The user's role.
+ */
+export const isStudent = (role: Enums<'roles_user'> | null): boolean => {
+ if (!role) {
+ return false;
+ }
+
+ return role === 'student';
+};
+
/**
* Check if the user is allowed to access or view the activity,
* based on the activity's visibility and the user's role.
@@ -54,7 +83,7 @@ export const canAccessActivity = (
case 'Internal':
return isInternal(role);
case 'Faculty':
- return isElevated(role);
+ return isPrivate(role);
default:
return true;
}