diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx index d7d90d0c..7236aab5 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx @@ -4,20 +4,29 @@ import { type LoaderFunctionArgs, redirect, } from '@remix-run/node'; -import { Form as RemixForm, useActionData } from '@remix-run/react'; +import { Form as RemixForm, useActionData, useFetcher } from '@remix-run/react'; +import { useEffect } from 'react'; import { Button, + ComboboxPopover, DatePicker, + Divider, Form, getErrors, Input, Modal, + MultiCombobox, + MultiComboboxDisplay, + MultiComboboxItem, + MultiComboboxSearch, + MultiComboboxValues, validateForm, } from '@oyster/ui'; import { createResumeBook } from '@/member-profile.server'; import { CreateResumeBookInput } from '@/member-profile.ui'; +import { type SearchCompaniesResult } from '@/routes/api.companies.search'; import { Route } from '@/shared/constants'; import { commitSession, @@ -48,6 +57,7 @@ export async function action({ request }: ActionFunctionArgs) { airtableTableId: data.airtableTableId, endDate: data.endDate, name: data.name, + sponsors: data.sponsors, startDate: data.startDate, }); @@ -77,41 +87,13 @@ export default function CreateResumeBookModal() { - - - - - - - - - + + + + + + + + + + + + + {error} @@ -153,3 +163,58 @@ export default function CreateResumeBookModal() { ); } + +function SponsorsField() { + const fetcher = useFetcher(); + const { errors } = getErrors(useActionData()); + + useEffect(() => { + fetcher.load('/api/companies/search'); + }, []); + + const companies = fetcher.data?.companies || []; + + return ( + + + + + { + fetcher.submit( + { search: e.currentTarget.value }, + { + action: '/api/companies/search', + method: 'get', + } + ); + }} + /> + + + +
    + {companies.map((company) => { + return ( + + {company.name} + + ); + })} +
+
+
+
+ ); +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx index 0bb0c7bc..88946454 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx @@ -6,9 +6,9 @@ import { import { Link, Outlet, useLoaderData } from '@remix-run/react'; import dayjs from 'dayjs'; import { useState } from 'react'; -import { Menu, Plus } from 'react-feather'; +import { ExternalLink, Menu, Plus } from 'react-feather'; -import { db } from '@oyster/db'; +import { listResumeBooks } from '@oyster/core/resume-books.server'; import { Dashboard, Dropdown, @@ -26,20 +26,9 @@ export async function loader({ request }: LoaderFunctionArgs) { const timezone = getTimezone(request); - const records = await db - .selectFrom('resumeBooks') - .select([ - 'airtableBaseId', - 'airtableTableId', - 'endDate', - 'id', - 'name', - 'startDate', - ]) - .orderBy('startDate', 'desc') - .execute(); - - const resumeBooks = records.map( + const _resumeBooks = await listResumeBooks(); + + const resumeBooks = _resumeBooks.map( ({ airtableBaseId, airtableTableId, endDate, startDate, ...record }) => { const format = 'MM/DD/YY @ h:mm A'; @@ -116,9 +105,14 @@ function ResumeBooksTable() { const columns: TableColumnProps[] = [ { displayName: 'Name', - size: '160', + size: '200', render: (resumeBook) => resumeBook.name, }, + { + displayName: '# of Submissions', + size: '160', + render: (resumeBook) => Number(resumeBook.submissions), + }, { displayName: 'Start Date', size: '200', @@ -131,11 +125,15 @@ function ResumeBooksTable() { }, { displayName: 'Airtable Link', - size: null, + size: '160', render: (resumeBook) => { return ( - - {resumeBook.airtableUri} + + Airtable ); }, diff --git a/apps/admin-dashboard/app/routes/api.companies.search.tsx b/apps/admin-dashboard/app/routes/api.companies.search.tsx new file mode 100644 index 00000000..5384b2ac --- /dev/null +++ b/apps/admin-dashboard/app/routes/api.companies.search.tsx @@ -0,0 +1,33 @@ +import { + json, + type LoaderFunctionArgs, + type SerializeFrom, +} from '@remix-run/node'; + +import { listCompanies } from '@oyster/core/employment.server'; + +import { ensureUserAuthenticated } from '@/shared/session.server'; + +export async function loader({ request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const url = new URL(request.url); + + const search = url.searchParams.get('search') || ''; + + const { companies } = await listCompanies({ + orderBy: 'most_employees', + pagination: { + limit: 100, + page: 1, + }, + select: ['companies.id', 'companies.name'], + where: { search }, + }); + + return json({ + companies, + }); +} + +export type SearchCompaniesResult = SerializeFrom; diff --git a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx index 5d99110f..6c258f1d 100644 --- a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx +++ b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx @@ -17,12 +17,18 @@ import { } from '@remix-run/react'; import { SubmitResumeInput } from '@oyster/core/resume-books'; -import { getResumeBook, submitResume } from '@oyster/core/resume-books.server'; +import { + getResumeBook, + listResumeBookSponsors, + submitResume, +} from '@oyster/core/resume-books.server'; import { FORMATTED_RACE, Race, WorkAuthorizationStatus } from '@oyster/types'; import { Button, Checkbox, + type DescriptionProps, Divider, + type FieldProps, Form, getErrors, Input, @@ -41,10 +47,12 @@ import { user, } from '@/shared/session.server'; -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ params, request }: LoaderFunctionArgs) { const session = await ensureUserAuthenticated(request); - const [member, resumeBook] = await Promise.all([ + const id = params.id as string; + + const [member, resumeBook, sponsors] = await Promise.all([ getMember(user(session)) .select([ 'email', @@ -58,7 +66,11 @@ export async function loader({ request }: LoaderFunctionArgs) { ]) .executeTakeFirst(), - getResumeBook(), + getResumeBook(id), + + listResumeBookSponsors({ + where: { resumeBookId: id }, + }), ]); if (!member) { @@ -72,6 +84,7 @@ export async function loader({ request }: LoaderFunctionArgs) { return json({ member, resumeBook, + sponsors, }); } @@ -119,6 +132,9 @@ export async function action({ params, request }: ActionFunctionArgs) { hometownLongitude: data.hometownLongitude, linkedInUrl: data.linkedInUrl, memberId: data.memberId, + preferredCompany1: data.preferredCompany1, + preferredCompany2: data.preferredCompany2, + preferredCompany3: data.preferredCompany3, race: data.race, resume: data.resume, resumeBookId: data.resumeBookId, @@ -142,16 +158,6 @@ export async function action({ params, request }: ActionFunctionArgs) { const keys = SubmitResumeInput.keyof().enum; -const RACES_IN_ORDER: Race[] = [ - 'black', - 'hispanic', - 'native_american', - 'middle_eastern', - 'white', - 'asian', - 'other', -]; - export default function ResumeBook() { const { member, resumeBook } = useLoaderData(); const { error, errors } = getErrors(useActionData()); @@ -319,6 +325,27 @@ export default function ResumeBook() { + + + + + + ); } + +function SponsorField({ + defaultValue, + description, + error, + name, +}: FieldProps & DescriptionProps) { + const { sponsors } = useLoaderData(); + + return ( + + + + ); +} diff --git a/package.json b/package.json index 1a4de91c..43f93baf 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "turbo run build --cache-dir=.turbo", "db:migrate": "yarn workspace @oyster/db migrate", + "db:migrate:down": "yarn workspace @oyster/db migrate:down", "db:migration:create": "yarn workspace @oyster/db migration:create", "db:seed": "yarn workspace @oyster/db seed", "db:types": "yarn workspace @oyster/db types", diff --git a/packages/core/src/modules/resume-book/index.server.ts b/packages/core/src/modules/resume-book/index.server.ts index f9de1a0e..39cf7cdb 100644 --- a/packages/core/src/modules/resume-book/index.server.ts +++ b/packages/core/src/modules/resume-book/index.server.ts @@ -1,2 +1,4 @@ export { getResumeBook } from './queries/get-resume-book'; +export { listResumeBooks } from './queries/list-resume-books'; +export { listResumeBookSponsors } from './queries/list-resume-book-sponsors'; export { submitResume } from './use-cases/submit-resume'; diff --git a/packages/core/src/modules/resume-book/queries/get-resume-book.ts b/packages/core/src/modules/resume-book/queries/get-resume-book.ts index 2ac8263d..ecc1663d 100644 --- a/packages/core/src/modules/resume-book/queries/get-resume-book.ts +++ b/packages/core/src/modules/resume-book/queries/get-resume-book.ts @@ -1,9 +1,10 @@ import { db } from '@oyster/db'; -export async function getResumeBook() { +export async function getResumeBook(id: string) { const resumeBook = await db .selectFrom('resumeBooks') .select(['id', 'name']) + .where('id', '=', id) .executeTakeFirst(); return resumeBook; diff --git a/packages/core/src/modules/resume-book/queries/list-resume-book-sponsors.ts b/packages/core/src/modules/resume-book/queries/list-resume-book-sponsors.ts new file mode 100644 index 00000000..9d5277b6 --- /dev/null +++ b/packages/core/src/modules/resume-book/queries/list-resume-book-sponsors.ts @@ -0,0 +1,18 @@ +import { db } from '@oyster/db'; + +type ListResumeBookSponsorsOptions = { + where: { resumeBookId: string }; +}; + +export async function listResumeBookSponsors({ + where, +}: ListResumeBookSponsorsOptions) { + const sponsors = await db + .selectFrom('resumeBookSponsors') + .leftJoin('companies', 'companies.id', 'resumeBookSponsors.companyId') + .select(['companies.id', 'companies.name']) + .where('resumeBookId', '=', where.resumeBookId) + .execute(); + + return sponsors; +} diff --git a/packages/core/src/modules/resume-book/queries/list-resume-books.ts b/packages/core/src/modules/resume-book/queries/list-resume-books.ts new file mode 100644 index 00000000..ac376d88 --- /dev/null +++ b/packages/core/src/modules/resume-book/queries/list-resume-books.ts @@ -0,0 +1,27 @@ +import { db } from '@oyster/db'; + +export async function listResumeBooks() { + const resumeBooks = await db + .selectFrom('resumeBooks') + .select([ + 'airtableBaseId', + 'airtableTableId', + 'endDate', + 'id', + 'name', + 'startDate', + + (eb) => { + return eb + .selectFrom('resumeBookSubmissions') + .select((eb) => eb.fn.countAll().as('submissions')) + .whereRef('resumeBooks.id', '=', 'resumeBookSubmissions.resumeBookId') + .as('submissions'); + }, + ]) + .orderBy('startDate', 'desc') + .orderBy('endDate', 'desc') + .execute(); + + return resumeBooks; +} diff --git a/packages/core/src/modules/resume-book/resume-book.types.ts b/packages/core/src/modules/resume-book/resume-book.types.ts index 8801912d..cddf698a 100644 --- a/packages/core/src/modules/resume-book/resume-book.types.ts +++ b/packages/core/src/modules/resume-book/resume-book.types.ts @@ -27,6 +27,12 @@ export const CreateResumeBookInput = ResumeBook.pick({ endDate: true, name: true, startDate: true, +}).extend({ + sponsors: z + .string() + .trim() + .min(1) + .transform((value) => value.split(',')), }); export const SubmitResumeInput = Student.pick({ @@ -43,6 +49,9 @@ export const SubmitResumeInput = Student.pick({ hometownLatitude: Student.shape.hometownLatitude.unwrap(), hometownLongitude: Student.shape.hometownLongitude.unwrap(), memberId: z.string().trim().min(1), + preferredCompany1: z.string().trim().min(1), + preferredCompany2: z.string().trim().min(1), + preferredCompany3: z.string().trim().min(1), resume: z.unknown().transform((value) => value as File), resumeBookId: z.string().trim().min(1), }); diff --git a/packages/core/src/modules/resume-book/use-cases/create-resume-book.ts b/packages/core/src/modules/resume-book/use-cases/create-resume-book.ts index 5f62d696..56794a00 100644 --- a/packages/core/src/modules/resume-book/use-cases/create-resume-book.ts +++ b/packages/core/src/modules/resume-book/use-cases/create-resume-book.ts @@ -4,15 +4,31 @@ import { db } from '@/infrastructure/database'; import { type CreateResumeBookInput } from '../resume-book.types'; export async function createResumeBook(input: CreateResumeBookInput) { - await db - .insertInto('resumeBooks') - .values({ - airtableBaseId: input.airtableBaseId, - airtableTableId: input.airtableTableId, - endDate: input.endDate, - id: id(), - name: input.name, - startDate: input.startDate, - }) - .execute(); + await db.transaction().execute(async (trx) => { + const resumeBookId = id(); + + await trx + .insertInto('resumeBooks') + .values({ + airtableBaseId: input.airtableBaseId, + airtableTableId: input.airtableTableId, + endDate: input.endDate, + id: resumeBookId, + name: input.name, + startDate: input.startDate, + }) + .execute(); + + await trx + .insertInto('resumeBookSponsors') + .values( + input.sponsors.map((sponsor) => { + return { + companyId: sponsor, + resumeBookId, + }; + }) + ) + .execute(); + }); } diff --git a/packages/core/src/modules/resume-book/use-cases/submit-resume.ts b/packages/core/src/modules/resume-book/use-cases/submit-resume.ts index cd0e5959..1b7e3543 100644 --- a/packages/core/src/modules/resume-book/use-cases/submit-resume.ts +++ b/packages/core/src/modules/resume-book/use-cases/submit-resume.ts @@ -15,6 +15,9 @@ export async function submitResume({ lastName, linkedInUrl, memberId, + preferredCompany1, + preferredCompany2, + preferredCompany3, race, resume, resumeBookId, @@ -81,6 +84,9 @@ export async function submitResume({ .values({ airtableRecordId: airtableRecordId as string, memberId, + preferredCompany1, + preferredCompany2, + preferredCompany3, resumeBookId, submittedAt: new Date(), }) diff --git a/packages/db/src/migrations/20240716014614_resume_book_sponsors.ts b/packages/db/src/migrations/20240716014614_resume_book_sponsors.ts new file mode 100644 index 00000000..0cecb8b1 --- /dev/null +++ b/packages/db/src/migrations/20240716014614_resume_book_sponsors.ts @@ -0,0 +1,86 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .createTable('resume_book_sponsors') + .addColumn('company_id', 'text', (column) => { + return column.notNull().references('companies.id'); + }) + .addColumn('resume_book_id', 'text', (column) => { + return column.notNull().references('resume_books.id'); + }) + .addPrimaryKeyConstraint('resume_book_sponsors_pkey', [ + 'company_id', + 'resume_book_id', + ]) + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .addColumn('preferred_company_1', 'text', (column) => { + return column.notNull(); + }) + .addColumn('preferred_company_2', 'text', (column) => { + return column.notNull(); + }) + .addColumn('preferred_company_3', 'text', (column) => { + return column.notNull(); + }) + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .addForeignKeyConstraint( + 'resume_book_submissions_preferred_company_1_fkey', + ['preferred_company_1', 'resume_book_id'], + 'resume_book_sponsors', + ['company_id', 'resume_book_id'] + ) + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .addForeignKeyConstraint( + 'resume_book_submissions_preferred_company_2_fkey', + ['preferred_company_2', 'resume_book_id'], + 'resume_book_sponsors', + ['company_id', 'resume_book_id'] + ) + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .addForeignKeyConstraint( + 'resume_book_submissions_preferred_company_3_fkey', + ['preferred_company_3', 'resume_book_id'], + 'resume_book_sponsors', + ['company_id', 'resume_book_id'] + ) + .execute(); +} + +export async function down(db: Kysely) { + await db.schema + .alterTable('resume_book_submissions') + .dropConstraint('resume_book_submissions_preferred_company_3_fkey') + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .dropConstraint('resume_book_submissions_preferred_company_2_fkey') + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .dropConstraint('resume_book_submissions_preferred_company_1_fkey') + .execute(); + + await db.schema + .alterTable('resume_book_submissions') + .dropColumn('preferred_company_1') + .dropColumn('preferred_company_2') + .dropColumn('preferred_company_3') + .execute(); + + await db.schema.dropTable('resume_book_sponsors').execute(); +} diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index ae4170ff..abc663a1 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -180,6 +180,10 @@ export async function validateForm( // Type Utilities +export type DescriptionProps = { + description?: string | React.ReactElement; +}; + export type FieldProps = { defaultValue?: T; error?: string; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a8bb7ed2..6e8d110a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -10,7 +10,7 @@ export { Divider } from './components/divider'; export { Dropdown } from './components/dropdown'; export { ExistingSearchParams } from './components/existing-search-params'; export { Form, getErrors, InputField, validateForm } from './components/form'; -export type { FieldProps } from './components/form'; +export type { DescriptionProps, FieldProps } from './components/form'; export { IconButton, getIconButtonCn } from './components/icon-button'; export { Input, getInputCn } from './components/input'; export type { InputProps } from './components/input';