diff --git a/.github/workflows/supabase-pull-request.yml b/.github/workflows/supabase-pull-request.yml deleted file mode 100644 index 58c3351..0000000 --- a/.github/workflows/supabase-pull-request.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy to Supabase on PR - -on: pull_request - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' # Ensure compatibility with Supabase CLI - - - name: Install dependencies - run: npm install - - - name: Install Supabase CLI locally - run: npm install supabase - - - name: Authenticate Supabase CLI - run: npx supabase login --token "${{ secrets.SUPABASE_ACCESS_TOKEN }}" - - - name: Link Supabase Project - run: npx supabase link --project-ref "${{ secrets.SUPABASE_PROJECT_REF }}" --password "${{ secrets.SUPABASE_DB_PWD }}" - - - name: Deploy migrations - run: npx supabase db push --password "${{ secrets.SUPABASE_DB_PWD }}" --include-all - - - name: Deploy Edge Functions - run: | - if [ -d "supabase/functions" ] && [ "$(ls -A supabase/functions)" ]; then - npx supabase functions deploy - else - echo "No functions to deploy." - fi diff --git a/src/api/ceCohortService.ts b/src/api/ceCohortService.ts index 463fb20..a9b27ec 100644 --- a/src/api/ceCohortService.ts +++ b/src/api/ceCohortService.ts @@ -17,12 +17,13 @@ class CECohortService extends EntityService { async create(): Promise { return studentService.findUnenrolled() .then(students => { - return cohortService.insert( - { - id: uuidv4(), - name: `(New) Cohort`, - } as Cohort - ) + return cohortService + .insert( + { + id: uuidv4(), + name: `(New) Cohort`, + } as Cohort + ) .then(cohort => { const enrollments = students.map(student => { return { @@ -38,9 +39,9 @@ class CECohortService extends EntityService { async getById(entityId: string | number, select?: string): Promise { try { - const cohort = await super.getById(entityId, select ?? '*, plan(*)') + const cohort = await super.getById(entityId, select ?? '*, enrollment(*), plan(*)'); + console.log(cohort); if (cohort) { - console.log(cohort) return { ...cohort, plans: [] // TODO join into plans diff --git a/src/api/ceEnrollmentService.ts b/src/api/ceEnrollmentService.ts index 9fe1cf1..146acb2 100644 --- a/src/api/ceEnrollmentService.ts +++ b/src/api/ceEnrollmentService.ts @@ -14,11 +14,15 @@ class CEEnrollmentService extends EntityService { async getStudents(cohort: Cohort): Promise { return await supabaseClient - .from('enrollment') + .from(this.tableName) .select('student(*)') .eq('cohort_id', cohort.id) - .then(resp => resp.data as unknown as Student[]); + .then(resp => { + const objects = resp.data as unknown as any[]; + return objects.map(ss => ss.student) as Student[]; + }); } + } const enrollmentService = new CEEnrollmentService('enrollment') diff --git a/src/api/cePlacementService.ts b/src/api/cePlacementService.ts new file mode 100644 index 0000000..3dbe95e --- /dev/null +++ b/src/api/cePlacementService.ts @@ -0,0 +1,26 @@ +/** + * ceCohortService.ts + * + * @copyright 2025 Digital Aid Seattle + * + */ + +import { supabaseClient } from "@digitalaidseattle/supabase"; +import { EntityService } from "./entityService"; +import { Identifier, Placement } from "./types"; + + +class CEPlacementService extends EntityService { + + async findByPlanId(planId: Identifier): Promise { + return await supabaseClient + .from(this.tableName) + .select('*, student(*)') + .eq('plan_id', planId) + .then(resp => resp.data as Placement[]); + } +} + +const placementService = new CEPlacementService('placement') +export { placementService }; + diff --git a/src/api/cePlanService.ts b/src/api/cePlanService.ts index 9601662..3bd52e4 100644 --- a/src/api/cePlanService.ts +++ b/src/api/cePlanService.ts @@ -5,104 +5,69 @@ * */ -import { Placement, Plan } from "./types"; +import { supabaseClient } from "@digitalaidseattle/supabase"; +import { v4 as uuidv4 } from 'uuid'; +import { placementService } from "./cePlacementService"; +import { EntityService } from "./entityService"; +import { Cohort, Identifier, Placement, Plan } from "./types"; +import { enrollmentService } from "./ceEnrollmentService"; -const TEST_PLAN = { - id: '1', - name: 'Plan1', - rating: 0, - notes: '', - cohort_id: "sess1", - placements: [ - { - id: '1', - cohort_id: '1', - student_id: 's1', - student: { - id: '', - name: 'Student 1', - age: null, - email: '', - city: '', - state: '', - country: '', - availabilities: [] - }, - anchor: true, - availabilities: [] - } , - { - id: '2', - cohort_id: '1', - student_id: 's2', - student: { - id: '', - name: 'Student 2', - age: null, - email: '', - city: '', - state: '', - country: '', - availabilities: [] - }, - anchor: false, - availabilities: [] - }, - { - id: '3', - cohort_id: '1', - student_id: 's3', - student: { - id: '', - name: 'Student 3', - age: null, - email: '', - city: '', - state: '', - country: '', - availabilities: [] - }, - anchor: false, - availabilities: [] - } - ] as Placement[], - groups: [ - { - id: undefined, - groupNo: 'Group 1', - studentIds: ['s1'] - }, - { - id: undefined, - groupNo: 'Group 2', - studentIds: ['s2', 's3'] - }, - { - id: undefined, - groupNo: 'Group 3', - studentIds: [] - } - ], -} as Plan; - -class CEPlanService { - async getById(id: string): Promise { - console.log("get plan", id); - return TEST_PLAN; +class CEPlanService extends EntityService { + async create(cohort: Cohort): Promise { + const proposed: Plan = { + id: uuidv4(), + name: 'New Plan', + note: '', + cohort_id: cohort.id + } as Plan + // + return enrollmentService.getStudents(cohort) + .then(students => { + return this.insert(proposed) + .then(plan => { + const placements = students.map(student => { + return { + plan_id: plan.id, + student_id: student.id, + anchor: false, + priority: 0 + } as Placement + }) + return placementService + .batchInsert(placements) + .then(createdPlacements => { + plan.placements = createdPlacements; + return plan; + }) + }) + }); } - async findByCohortId(cohortId: string): Promise { - console.log("get plans for cohort ", cohortId); - return [TEST_PLAN] + async duplicate(plan: Plan): Promise { + const proposed: Plan = { + id: uuidv4(), + name: plan.name + ' (copy)', + note: '', + cohort_id: plan.cohort_id + } as Plan + return this.insert(proposed) + .then(plan => { + // TODO copy placements + // TODO copy groups? + return plan; + }) } - async duplicate(plan: Plan): Promise { - alert(` '${plan.name}' would be duplicated ${plan.name}`) - return [{ ...TEST_PLAN }] + async findByCohortId(cohort_id: Identifier): Promise { + return await supabaseClient + .from(this.tableName) + .select('*') + .eq('cohort_id', cohort_id) + .then(resp => resp.data as Plan[]); } } -const planService = new CEPlanService() +const planService = new CEPlanService('plan') export { planService }; diff --git a/src/api/entityService.ts b/src/api/entityService.ts index b3b7778..3faa063 100644 --- a/src/api/entityService.ts +++ b/src/api/entityService.ts @@ -6,7 +6,7 @@ */ import { PageInfo, QueryModel, supabaseClient } from "@digitalaidseattle/supabase"; -import { Entity } from "./types"; +import { Entity, Identifier } from "./types"; abstract class EntityService { @@ -88,7 +88,7 @@ abstract class EntityService { } } - async batchInsert(entities: T[], select?: string): Promise { + async batchInsert(entities: T[], select?: string): Promise { try { const { data, error } = await supabaseClient .from(this.tableName) @@ -98,7 +98,7 @@ abstract class EntityService { console.error('Error inserting entity:', error.message); throw new Error('Failed to insert entity'); } - return data as unknown as T; + return data as unknown as T[]; } catch (err) { console.error('Unexpected error during insertion:', err); throw err; @@ -141,7 +141,7 @@ abstract class EntityService { } } - async delete(entityId: string): Promise { + async delete(entityId: Identifier): Promise { try { const { error } = await supabaseClient .from(this.tableName) diff --git a/src/api/types.ts b/src/api/types.ts index 7dfcd6a..47a553d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -4,8 +4,10 @@ * @copyright 2025 Digital Aid Seattle * */ +type Identifier = string | number; + type Entity = { - id: string | number; + id: Identifier; } type Availability = { @@ -54,25 +56,27 @@ type Group = { } type Enrollment = Entity & { - cohort_id: string | number; - student_id: string | number; + cohort_id: Identifier; + student_id: Identifier; } -type Placement = Entity & { - cohort_id: string; - student_id: string; +type Placement = Entity & { + plan_id: Identifier; + student_id: Identifier; student: Student; anchor: boolean; + priority: number; availabilities: Availability[]; } type Plan = Entity & { name: string; - cohort_id: string; - placements: Placement[] + cohort_id: Identifier; + numberOfGroups: number; + placements: Placement[]; groups: Group[]; rating: number; - notes: string; + note: string; } export type { @@ -80,6 +84,7 @@ export type { Enrollment, Entity, FailedStudent, + Identifier, Student, StudentField, SelectAvailability, diff --git a/src/components/PlanCard.tsx b/src/components/PlanCard.tsx index 1567ab1..d2c3bef 100644 --- a/src/components/PlanCard.tsx +++ b/src/components/PlanCard.tsx @@ -9,20 +9,38 @@ import { MoreOutlined } from "@ant-design/icons"; import { ConfirmationDialog } from "@digitalaidseattle/mui"; import { Card, CardContent, IconButton, Menu, MenuItem, Theme, Typography } from "@mui/material"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router"; import { planService } from "../api/cePlanService"; import { Plan } from "../api/types"; +import { useNotifications } from "@digitalaidseattle/core"; +import { placementService } from "../api/cePlacementService"; export const PlanCard = (props: { plan: Plan }) => { + const notifications = useNotifications(); + const [anchorEl, setAnchorEl] = useState(null); const showMenu = Boolean(anchorEl); + const [plan, setPlan] = useState(props.plan); + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); const navigate = useNavigate(); + useEffect(() => { + if (props.plan) { + placementService.findByPlanId(props.plan.id) + .then(placements => { + setPlan({ + ...props.plan, + placements: placements + }) + }) + } + }, [props]); + const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -32,27 +50,32 @@ export const PlanCard = (props: { plan: Plan }) => { }; const handleOpen = () => { - navigate(`/plan/${props.plan.id}`); + navigate(`/plan/${plan.id}`); setAnchorEl(null); }; const handleDuplicate = () => { - planService.duplicate(props.plan) + planService.duplicate(plan) setAnchorEl(null); }; - const handleDelete = () => { + const handleDelete = () => { setOpenDeleteDialog(true) setAnchorEl(null); }; - const doDelete = () => { - alert(' TODO delete plan') - setOpenDeleteDialog(false); - setAnchorEl(null); + const doDelete = () => { + if (plan) { + planService.delete(plan.id) + .then(() => { + notifications.success(`Deleted plan ${plan.name}.`); + setOpenDeleteDialog(false); + setAnchorEl(null); + }) + } }; - - return ( + + return (plan && { Delete... - {props.plan.name} - Notes : {props.plan.notes} - Stats : # of students, groups, etc. + {plan.name} + Notes : {plan.note} + Students : {plan.placements ? plan.placements.length : 0} doDelete()} handleCancel={() => setOpenDeleteDialog(false)} /> diff --git a/src/components/PlanDetails/GroupBoard.tsx b/src/components/PlanDetails/GroupBoard.tsx index 1b834c1..151dd36 100644 --- a/src/components/PlanDetails/GroupBoard.tsx +++ b/src/components/PlanDetails/GroupBoard.tsx @@ -38,7 +38,7 @@ export const GroupBoard = (props: { plan: Plan | undefined }) => { useEffect(() => { if (props.plan) { - setCategories(props.plan.groups.map(group => { + setCategories((props.plan.groups ?? []).map(group => { return { label: group.groupNo, value: group.groupNo } })) } @@ -51,7 +51,7 @@ export const GroupBoard = (props: { plan: Plan | undefined }) => { function isCategory(item: EnrollmentWrapper, category: DDCategory): boolean { if (props.plan) { const group = props.plan.groups.find(group => group.groupNo === category.value); - return group ? group.studentIds.includes(item.student_id) : false; + return group ? group.studentIds.includes(item.student_id as string) : false; } return false; } diff --git a/src/components/PlanDetails/Setup.tsx b/src/components/PlanDetails/Setup.tsx index 70ec560..f1a545e 100644 --- a/src/components/PlanDetails/Setup.tsx +++ b/src/components/PlanDetails/Setup.tsx @@ -152,7 +152,7 @@ export default function Setup() { { alert(`TODO save : ${text} name`)} /> - alert(`TODO note save : ${text}`)} /> + alert(`TODO note save : ${text}`)} /> diff --git a/src/pages/cohort/index.tsx b/src/pages/cohort/index.tsx index ea84047..d78d124 100644 --- a/src/pages/cohort/index.tsx +++ b/src/pages/cohort/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useParams } from 'react-router'; // material-ui @@ -10,45 +10,72 @@ import { MainCard } from '@digitalaidseattle/mui'; import { cohortService } from '../../api/ceCohortService'; import { PlanCard } from '../../components/PlanCard'; import { TextEdit } from '../../components/TextEdit'; -import { useNotifications } from '@digitalaidseattle/core'; -import { Cohort } from '../../api/types'; +import { LoadingContext, RefreshContext, useNotifications } from '@digitalaidseattle/core'; +import { Cohort, Plan } from '../../api/types'; +import { planService } from '../../api/cePlanService'; const CohortPage: React.FC = () => { const { id: cohortId } = useParams(); + const { refresh, setRefresh } = useContext(RefreshContext); + const { setLoading } = useContext(LoadingContext); + const notifications = useNotifications(); const [cohort, setCohort] = useState(); + const [plans, setPlans] = useState([]); useEffect(() => { if (cohortId) { cohortService.getById(cohortId) .then(cohort => setCohort(cohort)) } - }, [cohortId]); + }, [refresh, cohortId]); + + useEffect(() => { + if (cohort) { + planService.findByCohortId(cohort.id) + .then(plans => setPlans(plans)) + } + }, [cohort]); function handleNameChange(newText: string) { if (cohort) { cohortService - .update(cohort.id.toString(), { name: newText }) // FIXME change ID to UUID + .update(cohort.id.toString(), { name: newText }) .then(updated => { setCohort(updated); - notifications.success(`Cohort ${updated.name} updated.`) + notifications.success(`Cohort ${updated.name} updated.`); + setRefresh(refresh + 1); }); } } + function handleNewPlan() { + if (cohort) { + setLoading(true); + planService.create(cohort) + .then(plan => { + setRefresh(refresh + 1); + notifications.success(`Plan ${plan.name} created.`); + }) + .finally(() => setLoading(false)); + } + } + return (cohort && handleNameChange(val)} /> - + {/* Consider an alternate : switch between selected plan and all plans */} - - {cohort.plans.map(plan => - - )} - + + + {plans.map(plan => + + )} + + ) }; diff --git a/src/pages/plan/index.tsx b/src/pages/plan/index.tsx index 3b5d531..36c9eaa 100644 --- a/src/pages/plan/index.tsx +++ b/src/pages/plan/index.tsx @@ -16,7 +16,7 @@ const PlanPage: React.FC = () => { useEffect(() => { if (planId) { planService.getById(planId) - .then(p => setPlan(p)) + .then(p => setPlan(p!)) } }, [planId]) return (plan && diff --git a/supabase/migrations/000014_db_plan_updates.sql b/supabase/migrations/000014_db_plan_updates.sql new file mode 100644 index 0000000..8d96773 --- /dev/null +++ b/supabase/migrations/000014_db_plan_updates.sql @@ -0,0 +1,2 @@ +ALTER TABLE plan DROP COLUMN id CASCADE; +ALTER TABLE plan ADD COLUMN id UUID PRIMARY KEY; \ No newline at end of file diff --git a/supabase/migrations/000015_db_placement_updates.sql b/supabase/migrations/000015_db_placement_updates.sql new file mode 100644 index 0000000..4bd115f --- /dev/null +++ b/supabase/migrations/000015_db_placement_updates.sql @@ -0,0 +1,14 @@ +ALTER TABLE placement DROP COLUMN plan_id; +ALTER TABLE placement ADD COLUMN plan_id UUID REFERENCES plan(id); + +ALTER TABLE placement DROP COLUMN student_id; +ALTER TABLE placement ADD COLUMN student_id UUID REFERENCES student(id); + +ALTER TABLE placement ADD PRIMARY KEY (pland_id, student_id); + +ALTER TABLE placement +ADD CONSTRAINT placement_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES plan(id) ON DELETE CASCADE; + +-- ALTER TABLE placement +-- ADD CONSTRAINT placement_student_id_fkey FOREIGN KEY (student_id) REFERENCES student(id); +