diff --git a/apps/backend/src/applications/types.ts b/apps/backend/src/applications/types.ts index 65374c1c..638465d8 100644 --- a/apps/backend/src/applications/types.ts +++ b/apps/backend/src/applications/types.ts @@ -2,9 +2,9 @@ * Status of the application in the system/ review process */ export enum AppStatus { - APP_SUBMITTED = 'App submitted', - IN_REVIEW = 'In review', - FORMS_SENT = 'Forms sent', + APP_SUBMITTED = 'App Submitted', + IN_REVIEW = 'In Review', + FORMS_SIGNED = 'Forms Signed', ACCEPTED = 'Accepted', NO_AVAILABILITY = 'No Availability', DECLINED = 'Declined', diff --git a/apps/backend/src/migrations/1775221059185-AlterApplication.ts b/apps/backend/src/migrations/1775221059185-AlterApplication.ts new file mode 100644 index 00000000..741f2a8a --- /dev/null +++ b/apps/backend/src/migrations/1775221059185-AlterApplication.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AlterApplication1775221059185 implements MigrationInterface { + name = 'AlterApplication1775221059185'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."application_appstatus_enum" RENAME TO "application_appstatus_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."application_appstatus_enum" AS ENUM('App Submitted', 'In Review', 'Forms Signed', 'Accepted', 'No Availability', 'Declined', 'Active', 'Inactive')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" TYPE "public"."application_appstatus_enum" USING "appStatus"::"text"::"public"."application_appstatus_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" SET DEFAULT 'App Submitted'`, + ); + await queryRunner.query( + `DROP TYPE "public"."application_appstatus_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."application_appstatus_enum_old" AS ENUM('App submitted', 'In review', 'Forms sent', 'Accepted', 'No Availability', 'Declined', 'Active', 'Inactive')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" TYPE "public"."application_appstatus_enum_old" USING "appStatus"::"text"::"public"."application_appstatus_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" SET DEFAULT 'App submitted'`, + ); + await queryRunner.query(`DROP TYPE "public"."application_appstatus_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."application_appstatus_enum_old" RENAME TO "application_appstatus_enum"`, + ); + } +} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 3bd613e8..b881b4fc 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -1,6 +1,11 @@ import axios, { type AxiosInstance } from 'axios'; import { useCallback, useEffect, useState } from 'react'; -import { Application, AvailabilityFields, LearnerInfo } from './types'; +import { + Application, + AppStatus, + AvailabilityFields, + LearnerInfo, +} from './types'; const defaultBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; @@ -34,6 +39,15 @@ export class ApiClient { ) as Promise; } + public async updateApplicationStatus( + appId: number, + appStatus: AppStatus, + ): Promise { + return this.patch(`/api/applications/${appId}/status`, { + appStatus, + }) as Promise; + } + public async getTotalApplicationsCount(): Promise { const response = (await this.get('/api/applications/count/total')) as { count: number; diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 2c1a6c24..45c94138 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -2,9 +2,9 @@ * Status of the application in the system/ review process */ export enum AppStatus { - APP_SUBMITTED = 'App submitted', - IN_REVIEW = 'In review', - FORMS_SENT = 'Forms sent', + APP_SUBMITTED = 'App Submitted', + IN_REVIEW = 'In Review', + FORMS_SIGNED = 'Forms Signed', ACCEPTED = 'Accepted', NO_AVAILABILITY = 'No Availability', DECLINED = 'Declined', diff --git a/apps/frontend/src/components/ApplicantStageControl.tsx b/apps/frontend/src/components/ApplicantStageControl.tsx new file mode 100644 index 00000000..e0c928b8 --- /dev/null +++ b/apps/frontend/src/components/ApplicantStageControl.tsx @@ -0,0 +1,327 @@ +import { AppStatus } from '@api/types'; +import { + Box, + Button, + Flex, + Menu, + Popover, + Portal, + Text, +} from '@chakra-ui/react'; +import { useMemo, useState } from 'react'; +import { BsChevronDown, BsChevronUp } from 'react-icons/bs'; + +const STATUS_CONFIG: Record< + AppStatus, + { + label: string; + bg: string; + borderColor: string; + textColor?: string; + } +> = { + [AppStatus.APP_SUBMITTED]: { + label: 'App Submitted', + bg: '#FFF9E6', + borderColor: '#B8AF98', + }, + [AppStatus.IN_REVIEW]: { + label: 'In Review', + bg: '#DBEAFE', + borderColor: '#74C0E3', + }, + [AppStatus.FORMS_SIGNED]: { + label: 'Forms Signed', + bg: '#E9D5FF', + borderColor: '#A855F7', + }, + [AppStatus.ACCEPTED]: { + label: 'Accepted', + bg: '#F1F7EC', + borderColor: '#8BC34A', + }, + [AppStatus.NO_AVAILABILITY]: { + label: 'No Availability', + bg: '#D9D9D9', + borderColor: '#686868', + textColor: '#222222', + }, + [AppStatus.DECLINED]: { + label: 'Declined', + bg: '#FFD1D2', + borderColor: '#E66A6A', + }, + [AppStatus.ACTIVE]: { + label: 'Active', + bg: '#B9F0D0', + borderColor: '#43B581', + }, + [AppStatus.INACTIVE]: { + label: 'Inactive', + bg: '#F1F5F9', + borderColor: '#686868', + }, +}; + +const STATUS_ORDER: AppStatus[] = [ + AppStatus.IN_REVIEW, + AppStatus.ACCEPTED, + AppStatus.FORMS_SIGNED, + AppStatus.ACTIVE, + AppStatus.DECLINED, + AppStatus.INACTIVE, + AppStatus.NO_AVAILABILITY, + AppStatus.APP_SUBMITTED, +]; + +interface ApplicantStageControlProps { + value: AppStatus; + onConfirmChange: (status: AppStatus) => Promise; +} + +const ApplicantStageControl: React.FC = ({ + value, + onConfirmChange, +}) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [pendingStatus, setPendingStatus] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const currentStatus = STATUS_CONFIG[value]; + const pendingStatusConfig = pendingStatus + ? STATUS_CONFIG[pendingStatus] + : undefined; + + const orderedStatuses = useMemo(() => { + return STATUS_ORDER.filter((status) => status !== value); + }, [value]); + + const triggerBorderRadius = + isMenuOpen && pendingStatus === null ? '8px 8px 0 0' : '8px'; + + const handleStatusSelect = (nextStatus: AppStatus) => { + setIsMenuOpen(false); + + if (nextStatus === value) { + setPendingStatus(null); + return; + } + + setErrorMessage(null); + setPendingStatus(nextStatus); + }; + + const handleCancel = () => { + setPendingStatus(null); + setIsMenuOpen(false); + }; + + const handleConfirm = async () => { + if (!pendingStatus) return; + + setIsSaving(true); + setErrorMessage(null); + + try { + await onConfirmChange(pendingStatus); + setPendingStatus(null); + setIsMenuOpen(false); + } catch { + setErrorMessage('Failed to update application stage.'); + } finally { + setIsSaving(false); + } + }; + + return ( + + + setIsMenuOpen(details.open)} + > + + + + + + + + + + + {orderedStatuses.map((status) => { + const config = STATUS_CONFIG[status]; + + return ( + handleStatusSelect(status)} + bg={config.bg} + boxShadow={`inset 0 0 0 2px ${config.borderColor}`} + color={config.textColor ?? '#222222'} + fontFamily="Lato, sans-serif" + fontSize={{ base: '14px', md: '16px' }} + fontWeight={status === value ? '600' : '500'} + justifyContent="center" + py={{ base: '8px', md: '10px' }} + minH="unset" + _last={{ + borderBottomLeftRadius: '12px', + borderBottomRightRadius: '12px', + }} + _highlighted={{ + bg: config.bg, + filter: 'brightness(0.98)', + }} + > + {config.label} + + ); + })} + + + + + + + + + + + + + ! + + + Confirm Status + + + Change + + + {pendingStatusConfig + ? `This will change the stage to ${pendingStatusConfig.label} and may notify the applicant via email.` + : 'This will notify the applicant via email.'} + + + + + + + + {errorMessage && ( + + {errorMessage} + + )} + + + + + + + + ); +}; + +export default ApplicantStageControl; diff --git a/apps/frontend/src/components/SchoolAffiliationFrame.tsx b/apps/frontend/src/components/SchoolAffiliationFrame.tsx index eb1dfafa..d4d2a59c 100644 --- a/apps/frontend/src/components/SchoolAffiliationFrame.tsx +++ b/apps/frontend/src/components/SchoolAffiliationFrame.tsx @@ -1,4 +1,5 @@ import { Flex, Heading, Text, Circle } from '@chakra-ui/react'; +import type { ReactNode } from 'react'; export interface SchoolAffiliationProps { schoolName: string; @@ -9,6 +10,7 @@ export interface SchoolAffiliationProps { actualStartDate: string; endDate: string; totalTimeRequested: string; + statusControl?: ReactNode; } const SchoolAffiliationFrame = ({ @@ -20,6 +22,7 @@ const SchoolAffiliationFrame = ({ actualStartDate, endDate, totalTimeRequested, + statusControl, }: SchoolAffiliationProps) => { return ( - - School Affiliation - + + + School Affiliation + + {statusControl} + { const { appId } = useParams<{ appId: string }>(); @@ -49,6 +51,17 @@ const AdminViewApplication: React.FC = () => { setApplication((prev) => (prev ? { ...prev, ...updated } : prev)); }; + const handleStatusUpdate = async (nextStatus: AppStatus) => { + if (!application) return; + + const updatedApplication = await apiClient.updateApplicationStatus( + application.appId, + nextStatus, + ); + + setApplication(updatedApplication); + }; + if (loading) { return (
@@ -104,6 +117,12 @@ const AdminViewApplication: React.FC = () => { actualStartDate={''} endDate={''} totalTimeRequested={application.weeklyHours + ' hours per week'} + statusControl={ + + } /> diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index cee0be0d..03a55cd1 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -4,12 +4,18 @@ import react from '@vitejs/plugin-react'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import path from 'path'; +const projectRoot = __dirname; +const sharedRoot = path.resolve(projectRoot, '../../shared'); + export default defineConfig({ cacheDir: '../../node_modules/.vite/frontend', server: { port: 4200, host: 'localhost', + fs: { + allow: [projectRoot, sharedRoot], + }, }, preview: {