Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Feat/committee actions #199

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions actions/committee.actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use server';

import { and, count, eq } from 'drizzle-orm';

import { committeeMeetings, db } from '~/server/db';

export const addEditMeeting = async (
place: string,
agendaUrl: string,
minutesUrl: string,
createdAt: Date,
type: (typeof committeeMeetings.committeeType.enumValues)[number],
number?: number
): Promise<{ status: 'success' | 'error' }> => {
try {
if (number) {
await db
.update(committeeMeetings)
.set({ place, agendaUrl, minutesUrl, createdAt })
.where(
and(
eq(committeeMeetings.meetingNumber, number),
eq(committeeMeetings.committeeType, type)
)
);
} else {
await db.transaction(async (trx) => {
const [{ count: meetingCount }] = await trx
.select({ count: count() })
.from(committeeMeetings)
.where(eq(committeeMeetings.committeeType, type));

await trx.insert(committeeMeetings).values({
place,
agendaUrl,
minutesUrl,
createdAt,
committeeType: type,
meetingNumber: meetingCount + 1,
});
});
}
return {
status: 'success',
};
} catch (error) {
console.error(error);
return {
status: 'error',
};
}
};
205 changes: 205 additions & 0 deletions app/[locale]/@modals/(.)edit/client-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import { z } from 'zod';

import { Button } from '~/components/buttons';
import { Input } from '~/components/inputs';
import { FormField, FormItem, FormMessage } from '~/components/ui';
import type { committeeMeetings } from '~/server/db';
import { type Translations } from '~/i18n/translations';
import { addEditMeeting } from '~/actions/committee.actions';
import { useToast } from '~/lib/hooks';

export default function MeetingEdit({
number,
meetingType,
existingData,
text,
}: {
number?: number;
meetingType: (typeof committeeMeetings.committeeType.enumValues)[number];
existingData?: Omit<
typeof committeeMeetings.$inferSelect,
'meetingNumber' | 'committeeType'
>;
text: Translations['Committee'];
}) {
const { toast } = useToast();
const router = useRouter();
const meetingSchema = z
.object({
place: z.string().min(1, {
message: `${text.meetings.place} ${text.errors.required}`,
}),
agendaUrl: z.string().url({
message: `${text.errors.invalid} ${text.meetings.agenda} ${text.edit.url}`,
}),
minutesUrl: z.string().url({
message: `${text.errors.invalid} ${text.meetings.minutes} ${text.edit.url}`,
}),
createdDate: z
.string()
.min(1, { message: `${text.meetings.date} ${text.errors.required}` })
.refine(
(value) => {
const date = new Date(value);
return !isNaN(date.getTime());
},
{
message: `${text.errors.invalid} ${text.meetings.date}`,
}
),
createdTime: z
.string()
.regex(new RegExp(/^([0-1][0-9]|2[0-3]):[0-5][0-9]/), {
message: `${text.errors.invalid} ${text.edit.time}`,
}),
})
.transform((data) => {
const { createdDate, createdTime, ...rest } = data;
const [hours, minutes] = createdTime.split(':').map(Number);
const createdAt = new Date(createdDate);
createdAt.setHours(hours, minutes);

return {
...rest,
createdAt,
};
});
type meetingOutput = z.infer<typeof meetingSchema>;
type meetingInput = Omit<meetingOutput, 'createdAt'> & {
createdDate: string;
createdTime: string;
};
const form = useForm<meetingInput, meetingInput, meetingOutput>({
resolver: zodResolver(meetingSchema),
defaultValues: {
place: existingData?.place,
agendaUrl: existingData?.agendaUrl,
minutesUrl: existingData?.minutesUrl,
createdDate:
existingData &&
`${existingData.createdAt.getFullYear()}-${(existingData.createdAt.getMonth() + 1).toString().padStart(2, '0')}-${existingData.createdAt.getDate().toString().padStart(2, '0')}`,
createdTime: `${existingData?.createdAt.getHours().toString().padStart(2, '0')}:${existingData?.createdAt.getMinutes().toString().padStart(2, '0')}`,
},
});

const addOrEditMeeting = async (data: meetingOutput) => {
const result = await addEditMeeting(
data.place,
data.agendaUrl,
data.minutesUrl,
data.createdAt,
meetingType,
number
);
toast({
title: result.status === 'success' ? text.edit.success : text.edit.error,
variant: result.status,
});
router.back();
};

return (
<>
<h3>
{number ? text.edit.edit : text.edit.add}
{` ${meetingType.charAt(0).toUpperCase() + meetingType.slice(1)} ${number ? text.meetings.serial + number : text.edit.meeting}`}
</h3>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addOrEditMeeting)}>
<FormField
control={form.control}
name="place"
render={({ field }) => (
<FormItem reserveSpaceForError>
<Input
id={field.name}
label={`${text.meetings.place}`}
{...field}
/>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="agendaUrl"
render={({ field }) => (
<FormItem reserveSpaceForError>
<Input
id={field.name}
label={`${text.meetings.agenda} ${text.edit.url}`}
{...field}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minutesUrl"
render={({ field }) => (
<FormItem reserveSpaceForError>
<Input
id={field.name}
label={`${text.meetings.minutes} ${text.edit.url}`}
{...field}
/>
<FormMessage />
</FormItem>
)}
/>
<span className="flex gap-2">
<FormField
control={form.control}
name="createdDate"
render={({ field }) => (
<FormItem reserveSpaceForError>
<Input
id={field.name}
label={`${text.meetings.date}`}
type="date"
{...field}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="createdTime"
render={({ field }) => (
<FormItem reserveSpaceForError>
<Input
id={field.name}
label={`${text.edit.time}`}
type="time"
{...field}
/>
<FormMessage />
</FormItem>
)}
/>
</span>

<Button className="mr-2 p-2" type="submit" variant={'primary'}>
{text.edit.submit}
</Button>
<Button
type="button"
className="p-2"
onClick={() => router.back()}
variant={'secondary'}
>
{text.edit.back}
</Button>
</form>
</FormProvider>
</>
);
}
92 changes: 92 additions & 0 deletions app/[locale]/@modals/(.)edit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Suspense } from 'react';

import { Dialog } from '~/components/dialog';
import { cn } from '~/lib/utils';
import { type committeeMeetings, db } from '~/server/db';
import { getTranslations } from '~/i18n/translations';
import NotFound from '~/app/[...catchAll]/page';
import Loading from '~/components/loading';

import MeetingEdit from './client-utils';

export default async function Edit({
params: { locale },
searchParams: { no: number, type: categoryToEdit },
}: {
params: { locale: string };
searchParams: {
no?: string;
type: (typeof committeeMeetings.committeeType.enumValues)[number];
};
}) {
const text = (await getTranslations(locale)).Committee;
const renderContent = async () => {
if (
categoryToEdit === undefined ||
!['building', 'financial', 'governor', 'senate'].includes(categoryToEdit)
)
return (
<NotFound
params={{
catchAll: [],
locale: locale,
}}
/>
);

const existingData = !isNaN(Number(number))
? await db.query.committeeMeetings.findFirst({
where: (meeting, { eq, and }) =>
and(
eq(meeting.committeeType, categoryToEdit),
eq(meeting.meetingNumber, Number(number))
),
columns: {
id: true,
place: true,
agendaUrl: true,
createdAt: true,
minutesUrl: true,
},
})
: undefined;
if (!!!existingData && !isNaN(Number(number))) {
return (
<NotFound
params={{
catchAll: [],
locale: locale,
}}
/>
);
}
return (
<MeetingEdit
number={Number(number)}
meetingType={categoryToEdit}
existingData={existingData}
text={text}
/>
);
};
return (
<Dialog
className={cn(
'container',
'max-w-[384px] sm:max-w-[512px] md:max-w-[640px] lg:max-w-[640px]'
)}
disableClickOutside
>
<section
className={cn(
'rounded-lg border border-primary-500 bg-background',
'p-2 sm:p-6 md:p-10'
)}
>
<Suspense fallback={<Loading className="h-96" />}>
{renderContent()}
</Suspense>
</section>
</Dialog>
);
}
Loading