diff --git a/.gitignore b/.gitignore
index de44e4f..5cbdccf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,9 @@
/.pnp
.pnp.js
+# debugging images
+/images
+
# testing
/coverage
diff --git a/docs/documentation.md b/docs/documentation.md
index 33b401f..cff9114 100644
--- a/docs/documentation.md
+++ b/docs/documentation.md
@@ -184,7 +184,7 @@ Event region and city options live in `src/lib/eventOptions.ts`.
- Supports filters for `employmentType`, `compensationType`, `organizationIndustry`, and `jobStatus`.
- Non-admin requests are paginated with `page` and `limit`.
- Admin requests use `admin=true` and return all matching records.
-- Approved non-admin results exclude old expired approved jobs by approved date.
+- Approved non-admin results include all approved jobs, regardless of approval date.
- Sorting prioritizes `memberJob` first, then approved date or post date.
`POST /api/jobs`
@@ -205,8 +205,8 @@ Event region and city options live in `src/lib/eventOptions.ts`.
- Requires `nonprofit` or `spokes_admin`.
- Nonprofits can update only their own jobs.
- Nonprofit edits send the job back to `pending`.
-- Supports renewal and unpublish actions.
-- Admin status changes can approve, reject, or expire jobs.
+- Supports renewal and resolve actions.
+- Admin status changes can approve, reject, or resolve jobs.
`DELETE /api/jobs/[jobId]`
@@ -362,18 +362,18 @@ Shows the authenticated user's jobs. Organization users can:
- Edit jobs
- View rejection feedback
-- Renew expired jobs
-- Unpublish approved jobs
+- Reopen resolved jobs
+- Resolve approved jobs
### Job Admin Dashboard
File: `src/components/jobs/pages/JobsAdminPage.tsx`
-Shows pending, approved, rejected, and expired jobs. Admins can:
+Shows pending, approved, rejected, and resolved jobs. Admins can:
- Approve jobs
- Reject jobs with a reason
-- Renew or expire jobs through status actions
+- Reopen or resolve jobs through status actions
- Refresh dashboard data
- Navigate to user management
diff --git a/docs/owner-user-guide.md b/docs/owner-user-guide.md
index aef8e82..afedc47 100644
--- a/docs/owner-user-guide.md
+++ b/docs/owner-user-guide.md
@@ -62,17 +62,17 @@ Approved jobs appear on the public job board. Rejected jobs stay available to th
- Pending: waiting for Spokes admin review.
- Approved: visible on the public job board.
- Rejected: not public; the organization can view feedback.
-- Expired: no longer active.
+- Resolved: no longer active or visible on the public job board.
-Approved jobs expire after their active period. The admin dashboard checks for expired jobs when it loads and periodically while open.
+Jobs stay active until the posting organization resolves or deletes them.
-### Edit, Renew, or Unpublish Jobs
+### Edit, Reopen, or Resolve Jobs
Organizations can use `/jobs/manage` to manage their own jobs.
- Edit: updates the job and sends it back to pending review.
-- Renew: restarts an expired job.
-- Unpublish: marks an approved job as expired so it is no longer active.
+- Reopen: sends a resolved job back for review.
+- Resolve Job: marks an approved job as resolved so it is no longer active.
- View Feedback: shows the admin's rejection reason.
## Managing Events
@@ -193,7 +193,7 @@ Check its status:
- Pending items are waiting for approval.
- Rejected items are not public.
-- Expired jobs are not active.
+- Resolved jobs are not active.
- Approved items should appear publicly.
Also try refreshing the admin page with the refresh button.
@@ -236,7 +236,7 @@ Owners can usually handle:
- Updating user organizations
- Deleting users
- Deleting organizations
-- Renewing or unpublishing jobs from the organization dashboard
+- Reopening or resolving jobs from the organization dashboard
Owners should ask a technical maintainer for:
diff --git a/package.json b/package.json
index 87610ca..05decb5 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"framer-motion": "^11.15.0",
"lucide-react": "^0.475.0",
"mongoose": "^8.14.1",
- "next": ">=14.2.25",
+ "next": "16.2.6",
"next-themes": "^0.4.4",
"react": "^18",
"react-dom": "^18",
diff --git a/src/app/api/__test__/AdminPage.test.tsx b/src/app/api/__test__/AdminPage.test.tsx
index 5d585de..17303cf 100644
--- a/src/app/api/__test__/AdminPage.test.tsx
+++ b/src/app/api/__test__/AdminPage.test.tsx
@@ -190,8 +190,8 @@ describe("Admin Jobs Page", () => {
});
expect(screen.getByText("Org2")).toBeInTheDocument();
- // Click on Expired Jobs tab
- fireEvent.click(screen.getByText(/Expired Jobs/i));
+ // Click on Resolved Jobs tab
+ fireEvent.click(screen.getByText(/Resolved Jobs/i));
// Check for expired jobs
await waitFor(() => {
diff --git a/src/app/api/__test__/EventsApi.test.ts b/src/app/api/__test__/EventsApi.test.ts
index 159a77c..e655db2 100644
--- a/src/app/api/__test__/EventsApi.test.ts
+++ b/src/app/api/__test__/EventsApi.test.ts
@@ -2,7 +2,7 @@ import Event from "@/database/eventSchema";
import User from "@/database/userSchema";
import { resolveOrganizationName } from "@/lib/organizations";
import { GET, POST } from "@/app/api/events/route";
-import { GET as GET_EVENT, PUT } from "@/app/api/events/[eventId]/route";
+import { DELETE, GET as GET_EVENT, PUT } from "@/app/api/events/[eventId]/route";
const mockAuth = {
userId: "user-1",
@@ -26,6 +26,7 @@ jest.mock("@/database/eventSchema", () => ({
find: jest.fn(),
findById: jest.fn(),
findByIdAndUpdate: jest.fn(),
+ findByIdAndDelete: jest.fn(),
findOneAndUpdate: jest.fn(),
},
}));
@@ -241,11 +242,59 @@ describe("Events API", () => {
locationLink: "https://maps.example.com/updated-event",
eventLocationGeneral: "North Coast",
eventLocationCity: "Morro Bay",
+ eventStatus: "pending",
+ rejectionMessage: "",
},
{ new: true, strict: false },
);
});
+ test("resubmits an owned approved event for review after editing", async () => {
+ (Event.findById as jest.Mock).mockResolvedValue({
+ _id: "event-1",
+ createdByUserId: "user-1",
+ eventStatus: "approved",
+ });
+ (Event.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "event-1", eventStatus: "pending" });
+
+ const response = await PUT(jsonRequest("/api/events/event-1", { eventName: "Updated Event" }), {});
+
+ expect(response.status).toBe(200);
+ expect(Event.findByIdAndUpdate).toHaveBeenCalledWith(
+ "event-1",
+ { eventName: "Updated Event", eventStatus: "pending", rejectionMessage: "" },
+ { new: true, strict: false },
+ );
+ });
+
+ test("allows an admin to reject an event with feedback", async () => {
+ mockAuth.role = "spokes_admin";
+ (Event.findById as jest.Mock).mockResolvedValue({ _id: "event-1", createdByUserId: "user-1" });
+ (Event.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "event-1", eventStatus: "rejected" });
+
+ const response = await PUT(
+ jsonRequest("/api/events/event-1", { eventStatus: "rejected", rejectionMessage: "Missing event details" }),
+ {},
+ );
+
+ expect(response.status).toBe(200);
+ expect(Event.findByIdAndUpdate).toHaveBeenCalledWith(
+ "event-1",
+ { eventStatus: "rejected", rejectionMessage: "Missing event details" },
+ { new: true },
+ );
+ });
+
+ test("allows an owner to delete an event", async () => {
+ (Event.findById as jest.Mock).mockResolvedValue({ _id: "event-1", createdByUserId: "user-1" });
+ (Event.findByIdAndDelete as jest.Mock).mockResolvedValue({ _id: "event-1" });
+
+ const response = await DELETE({ nextUrl: { pathname: "/api/events/event-1" } } as any, {});
+
+ expect(response.status).toBe(200);
+ expect(Event.findByIdAndDelete).toHaveBeenCalledWith("event-1");
+ });
+
test("GET blocks non-admin requests for private event statuses", async () => {
mockAuth.userId = null as any;
mockAuth.role = "job_seeker";
diff --git a/src/app/api/__test__/EventsValidation.test.ts b/src/app/api/__test__/EventsValidation.test.ts
index cb0e709..139ba7c 100644
--- a/src/app/api/__test__/EventsValidation.test.ts
+++ b/src/app/api/__test__/EventsValidation.test.ts
@@ -49,4 +49,16 @@ describe("event validation service", () => {
expect(validateEventPayload({ majorFundraisingEvent: false }, { partial: true })).toBeNull();
});
+
+ test("allows a new event without a time or venue", () => {
+ const payload = { ...validPayload, time: "", location: "" };
+
+ expect(validateEventPayload(payload)).toBeNull();
+ expect(sanitizeEventPayload(payload)).toEqual(
+ expect.objectContaining({
+ time: "",
+ location: "",
+ }),
+ );
+ });
});
diff --git a/src/app/api/__test__/JobsApi.test.ts b/src/app/api/__test__/JobsApi.test.ts
index ba1800f..0722f7e 100644
--- a/src/app/api/__test__/JobsApi.test.ts
+++ b/src/app/api/__test__/JobsApi.test.ts
@@ -186,7 +186,7 @@ describe("Jobs API", () => {
expect(Job.find).not.toHaveBeenCalled();
});
- test("GET defaults public job listings to approved, unexpired jobs", async () => {
+ test("GET defaults public job listings to all approved jobs", async () => {
mockAuth.userId = null as any;
mockAuth.role = "job_seeker";
const limit = jest.fn().mockResolvedValue([]);
@@ -202,10 +202,7 @@ describe("Jobs API", () => {
);
expect(response.status).toBe(200);
- expect(Job.find).toHaveBeenCalledWith({
- jobStatus: "approved",
- approvedDate: { $gte: expect.any(Date) },
- });
+ expect(Job.find).toHaveBeenCalledWith({ jobStatus: "approved" });
});
test("GET job detail hides private jobs from anonymous users", async () => {
@@ -224,6 +221,34 @@ describe("Jobs API", () => {
expect(result.message).toBe("Job not found");
});
+ test("GET job detail keeps an approved legacy job public without an approval date", async () => {
+ mockAuth.userId = null as any;
+ mockAuth.role = "job_seeker";
+ (Job.findById as jest.Mock).mockResolvedValue({
+ _id: "job-1",
+ userId: "user-1",
+ jobStatus: "approved",
+ });
+
+ const response = await GET_JOB({ nextUrl: { pathname: "/api/jobs/job-1" } } as any, {});
+
+ expect(response.status).toBe(200);
+ });
+
+ test("allows an organization to resolve its job", async () => {
+ (Job.findById as jest.Mock).mockResolvedValue({ _id: "job-1", userId: "user-1", jobStatus: "approved" });
+ (Job.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "job-1", jobStatus: "expired" });
+
+ const response = await PUT(jsonRequest("/api/jobs/job-1", { isResolve: true }), {});
+
+ expect(response.status).toBe(200);
+ expect(Job.findByIdAndUpdate).toHaveBeenCalledWith(
+ "job-1",
+ { jobStatus: "expired", modifiedDate: expect.any(Date) },
+ { new: true },
+ );
+ });
+
test("PUT only writes whitelisted mutable job fields", async () => {
(Job.findById as jest.Mock).mockResolvedValue({
_id: "job-1",
diff --git a/src/app/api/events/[eventId]/route.ts b/src/app/api/events/[eventId]/route.ts
index bb83ecd..7a2b439 100644
--- a/src/app/api/events/[eventId]/route.ts
+++ b/src/app/api/events/[eventId]/route.ts
@@ -80,11 +80,6 @@ export const PUT = withApiAuth(
return NextResponse.json({ message: "Event status updated successfully", event: updatedEvent });
}
- // Handle regular updates (nonprofit can update their own pending events)
- if (auth.role === "nonprofit" && existingEvent.eventStatus !== "pending") {
- return NextResponse.json({ message: "Can only edit pending events" }, { status: 403 });
- }
-
const validationError = validateEventPayload(eventData, { partial: true });
if (validationError) {
return NextResponse.json({ message: validationError }, { status: 400 });
@@ -95,7 +90,13 @@ export const PUT = withApiAuth(
return NextResponse.json({ message: "No valid event fields provided" }, { status: 400 });
}
- const updatedEvent = await Event.findByIdAndUpdate(eventId, sanitizedEventData, { new: true, strict: false });
+ // Organization changes must be reviewed again. Admin edits remain live because
+ // they are already made by the reviewing role.
+ const updateData =
+ auth.role === "nonprofit"
+ ? { ...sanitizedEventData, eventStatus: EventStatus.pending, rejectionMessage: "" }
+ : sanitizedEventData;
+ const updatedEvent = await Event.findByIdAndUpdate(eventId, updateData, { new: true, strict: false });
return NextResponse.json({ message: "Event updated successfully", event: updatedEvent });
} catch (error) {
return NextResponse.json({ message: "Error updating event", error }, { status: 500 });
diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts
index 07a573b..8b86633 100644
--- a/src/app/api/events/route.ts
+++ b/src/app/api/events/route.ts
@@ -94,8 +94,8 @@ export const POST = withApiAuth(
organization,
date: new Date(sanitizedEventData.date as string),
eventName: sanitizedEventData.eventName as string,
- time: sanitizedEventData.time as string,
- location: sanitizedEventData.location as string,
+ time: (sanitizedEventData.time as string) || "",
+ location: (sanitizedEventData.location as string) || "",
};
const duplicateUpdateFields: Record = {};
const insertEventData = { ...sanitizedEventData };
diff --git a/src/app/api/jobs/[jobId]/route.ts b/src/app/api/jobs/[jobId]/route.ts
index e7ae625..6cf64d0 100644
--- a/src/app/api/jobs/[jobId]/route.ts
+++ b/src/app/api/jobs/[jobId]/route.ts
@@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server";
import Job from "@/database/jobSchema";
import { withApiAuth } from "@/lib/auth";
import { JobStatus } from "@/database/jobSchema";
-import { isExpired } from "@/lib/utils";
const mutableJobFields = [
"organizationIndustry",
@@ -75,7 +74,7 @@ export const PUT = withApiAuth(
const jobId = req.nextUrl.pathname.split("/").pop();
const requestData = await req.json();
- const { previousStatus, newStatus, isRenewal, isUnpublish, ...jobData } = requestData;
+ const { previousStatus, newStatus, isRenewal, isResolve, isUnpublish, ...jobData } = requestData;
// Handle both cases - with and without status transition data
const hasStatusTransition = previousStatus !== undefined && newStatus !== undefined;
@@ -110,8 +109,9 @@ export const PUT = withApiAuth(
return NextResponse.json({ message: "Job renewed successfully", job: updatedJob });
}
- // Handle job unpublishing
- if (isUnpublish) {
+ // Resolve removes a listing from the public board while retaining it for
+ // the organization and admins. Keep isUnpublish for older clients.
+ if (isResolve || isUnpublish) {
const updatedJob = await Job.findByIdAndUpdate(
jobId,
{
@@ -120,7 +120,7 @@ export const PUT = withApiAuth(
},
{ new: true },
);
- return NextResponse.json({ message: "Job unpublished successfully", job: updatedJob });
+ return NextResponse.json({ message: "Job resolved successfully", job: updatedJob });
}
const nextJobStatus = hasStatusTransition
@@ -171,7 +171,7 @@ export const GET = withApiAuth(
return NextResponse.json({ message: "Job not found" }, { status: 404 });
}
- const isPubliclyVisible = job.jobStatus === JobStatus.approved && !isExpired(job.jobStatus, job.approvedDate);
+ const isPubliclyVisible = job.jobStatus === JobStatus.approved;
const canViewPrivateJob = auth.role === "spokes_admin" || (auth.userId && job.userId === auth.userId);
if (!isPubliclyVisible && !canViewPrivateJob) {
diff --git a/src/app/api/jobs/recent/route.ts b/src/app/api/jobs/recent/route.ts
index eef11ee..137afc3 100644
--- a/src/app/api/jobs/recent/route.ts
+++ b/src/app/api/jobs/recent/route.ts
@@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from "next/server";
import Job from "@/database/jobSchema";
import { ObjectId } from "mongodb";
import { withApiAuth } from "@/lib/auth";
-import { getThirtyDaysAgo } from "@/lib/utils";
export const POST = withApiAuth(
async (req: NextRequest) => {
@@ -22,7 +21,6 @@ export const POST = withApiAuth(
const recentJobs = await Job.find({
_id: { $in: jobIdArray },
jobStatus: "approved",
- approvedDate: { $gte: getThirtyDaysAgo() },
});
return NextResponse.json(recentJobs, { status: 200 });
} catch (error) {
diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts
index 0ffd047..7a6cde1 100644
--- a/src/app/api/jobs/route.ts
+++ b/src/app/api/jobs/route.ts
@@ -4,7 +4,6 @@ import Job from "@/database/jobSchema";
import User from "@/database/userSchema";
import { withApiAuth } from "@/lib/auth";
import { resolveOrganizationName } from "@/lib/organizations";
-import { getThirtyDaysAgo } from "@/lib/utils";
// no filters: GET /api/jobs?page=1&limit=10
// filter by employment type: GET /api/jobs?employmentType=full-time&employment=part-time
@@ -69,18 +68,8 @@ export const GET = withApiAuth(
filter.jobStatus = {
$in: statusFilter,
};
-
- // Filter out expired jobs that are already approved
- if (statusFilter == "approved" && !isAdminRequest) {
- filter.approvedDate = {
- $gte: getThirtyDaysAgo(),
- };
- }
} else if (!isAdminRequest) {
filter.jobStatus = "approved";
- filter.approvedDate = {
- $gte: getThirtyDaysAgo(),
- };
}
if (statusFilter === "approved") {
diff --git a/src/app/api/send/event-new/route.ts b/src/app/api/send/event-new/route.ts
index 93acae5..e91f7e0 100644
--- a/src/app/api/send/event-new/route.ts
+++ b/src/app/api/send/event-new/route.ts
@@ -30,8 +30,8 @@ export const POST = withApiAuth(
description: eventData.description,
organization: eventData.organization,
date: new Date(eventData.date).toLocaleDateString(),
- time: eventData.time,
- location: eventData.location,
+ time: eventData.time || "TBD",
+ location: eventData.location || "TBD",
contactName: eventData.contactName,
contactEmail: eventData.contactEmail,
adminURL: getEventsAdminUrl(),
diff --git a/src/app/dashboard/DashboardPage.tsx b/src/app/dashboard/DashboardPage.tsx
index c8bbdc1..4b851cc 100644
--- a/src/app/dashboard/DashboardPage.tsx
+++ b/src/app/dashboard/DashboardPage.tsx
@@ -139,16 +139,13 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da
);
}
- const liveJobs = userJobs.filter(
- (job) => job.jobStatus.toLowerCase() === "approved" && !isExpired(job.jobStatus, job.approvedDate),
- );
+ const liveJobs = userJobs.filter((job) => job.jobStatus.toLowerCase() === "approved" && !isExpired(job.jobStatus));
const pendingJobs = userJobs.filter(
(job) => job.jobStatus.toLowerCase() === "pending" || job.jobStatus.toLowerCase() === "rejected",
);
- // Filter expired jobs by if the approval date is greater than 30 days ago or if the job status is "expired"
- const expiredJobs = userJobs.filter((job) => isExpired(job.jobStatus, job.approvedDate));
+ const resolvedJobs = userJobs.filter((job) => isExpired(job.jobStatus));
return (
@@ -173,7 +170,7 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da
))
@@ -195,14 +192,14 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da
-
Expired Jobs
+
Resolved Jobs
- {expiredJobs.length > 0 ? (
- expiredJobs.map((job, index) => (
-
+ {resolvedJobs.length > 0 ? (
+ resolvedJobs.map((job, index) => (
+
))
) : (
-
No expired jobs available
+
No resolved jobs available
)}
diff --git a/src/app/events/[eventId]/page.tsx b/src/app/events/[eventId]/page.tsx
index 1f20661..0c82765 100644
--- a/src/app/events/[eventId]/page.tsx
+++ b/src/app/events/[eventId]/page.tsx
@@ -49,10 +49,10 @@ export default async function EventPage({ params }: EventPageProps) {
Date: {formatDate(event.date)}
- Time: {event.time}
+ Time: {event.time || "TBD"}
- Location: {event.location}
+ Location: {event.location || "TBD"}
{(eventCity || eventRegion) && (
diff --git a/src/components/events/EventCard.tsx b/src/components/events/EventCard.tsx
index 6b06c08..b0b8062 100644
--- a/src/components/events/EventCard.tsx
+++ b/src/components/events/EventCard.tsx
@@ -59,40 +59,36 @@ export default function EventCard({ event, onEventView }: EventCardProps) {
{formatDate(event.date)}
- {event.time && (
-
-
-
-
-
- {event.time}
-
- )}
- {event.location && (
-
-
-
-
-
- {event.location}
-
- )}
+
+
+
+
+
+ {event.time || "Time TBD"}
+
+
+
+
+
+
+ {event.location || "Location TBD"}
+
{(eventCity || eventRegion) && (
{[eventCity, eventRegion].filter(Boolean).join(", ")}
)}
diff --git a/src/components/events/EventCard/AdminEventCard.tsx b/src/components/events/EventCard/AdminEventCard.tsx
index 111c4ff..fc27006 100644
--- a/src/components/events/EventCard/AdminEventCard.tsx
+++ b/src/components/events/EventCard/AdminEventCard.tsx
@@ -6,7 +6,9 @@ import JobCardModal from "@/components/jobs/JobCard/JobCardModal";
import ActionButton from "@/components/jobs/JobCard/ActionButton";
import JobPostedDate from "@/components/jobs/JobCard/JobPostedDate";
import { getEventInfoLink, getEventLocationLink } from "@/lib/eventLinks";
-import { useToast } from "@chakra-ui/react";
+import { IconButton } from "@chakra-ui/react";
+import { FiEdit } from "react-icons/fi";
+import { useRouter } from "next/navigation";
interface AdminEventCardProps {
event: IEvent;
@@ -25,7 +27,7 @@ export default function AdminEventCard({ event, onUpdateEvent, innerRef }: Admin
const [rejectionReason, setRejectionReason] = useState("");
const [isNewIndicatorDismissed, setIsNewIndicatorDismissed] = useState(false);
const [isLoading, setIsLoading] = useState<"approve" | "reject" | null>(null);
- const toast = useToast();
+ const router = useRouter();
useEffect(() => {
const dismissedState = localStorage.getItem(`new-event-indicator-${event._id}`);
@@ -101,6 +103,15 @@ export default function AdminEventCard({ event, onUpdateEvent, innerRef }: Admin
return (
+ }
+ size="sm"
+ borderColor="black"
+ position="absolute"
+ className="absolute top-4 right-[1rem]"
+ onClick={() => router.push(`/events/list?eventId=${event._id}&returnURL=/events/admin`)}
+ />
{/* New indicator dot */}
{new Date(event.createdAt ?? event.date).getTime() > Date.now() - 24 * 60 * 60 * 1000 &&
!isNewIndicatorDismissed && (
@@ -126,10 +137,10 @@ export default function AdminEventCard({ event, onUpdateEvent, innerRef }: Admin
Date: {formattedDate}
- Time: {event.time}
+ Time: {event.time || "TBD"}
- Location: {event.location}
+ Location: {event.location || "TBD"}
{(eventCity || eventRegion) && (
diff --git a/src/components/events/EventCard/OrgEventCard.tsx b/src/components/events/EventCard/OrgEventCard.tsx
index 44de70b..59c5be7 100644
--- a/src/components/events/EventCard/OrgEventCard.tsx
+++ b/src/components/events/EventCard/OrgEventCard.tsx
@@ -14,7 +14,6 @@ import {
ModalCloseButton,
useDisclosure,
Text,
- useToast,
} from "@chakra-ui/react";
import { FiEdit, FiMessageSquare } from "react-icons/fi";
import { useRouter } from "next/navigation";
@@ -22,14 +21,12 @@ import { useRouter } from "next/navigation";
export interface OrgEventCardProps extends ComponentProps<"div"> {
className?: string;
event: IEvent;
- onEventStatusUpdate?: (event: IEvent) => void;
}
export const OrgEventCard = forwardRef
(
- ({ children, className, event, onEventStatusUpdate, ...props }, ref) => {
+ ({ children, className, event, ...props }, ref) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const router = useRouter();
- const toast = useToast();
const eventDate = new Date(event.date).toLocaleDateString("en-US", {
month: "short",
@@ -40,9 +37,9 @@ export const OrgEventCard = forwardRef(
event.eventLocationGeneral === "Other" ? event.eventLocationGeneralOther : event.eventLocationGeneral;
const eventCity = event.eventLocationCity === "Other" ? event.eventLocationCityOther : event.eventLocationCity;
- function handleEditButton(e: React.ChangeEvent) {
+ function handleEditButton(e: React.MouseEvent) {
e.preventDefault();
- router.push(`/events/list?eventId=${event._id}`);
+ router.push(`/events/list?eventId=${event._id}&returnURL=/events/manage`);
}
return (
@@ -61,7 +58,7 @@ export const OrgEventCard = forwardRef(
- {eventDate} · {event.time} · {event.location}
+ {[eventDate, event.time || "Time TBD", event.location || "Location TBD"].join(" · ")}
{(eventCity || eventRegion) && (
{[eventCity, eventRegion].filter(Boolean).join(", ")}
@@ -84,18 +81,16 @@ export const OrgEventCard = forwardRef
(
Feedback
)}
- {(event.eventStatus === "pending" || event.eventStatus === "rejected") && (
-
-
- Edit
-
- )}
+
+
+ Edit
+
diff --git a/src/components/events/EventModals/EventConfirmationModal.tsx b/src/components/events/EventModals/EventConfirmationModal.tsx
index 503127a..35f2ac2 100644
--- a/src/components/events/EventModals/EventConfirmationModal.tsx
+++ b/src/components/events/EventModals/EventConfirmationModal.tsx
@@ -17,9 +17,16 @@ import { useRouter } from "next/navigation";
interface EventConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
+ onCreateAnother: () => void;
+ submittedForReview: boolean;
}
-export default function EventConfirmationModal({ isOpen, onClose }: EventConfirmationModalProps) {
+export default function EventConfirmationModal({
+ isOpen,
+ onClose,
+ onCreateAnother,
+ submittedForReview,
+}: EventConfirmationModalProps) {
const router = useRouter();
return (
@@ -37,13 +44,14 @@ export default function EventConfirmationModal({ isOpen, onClose }: EventConfirm
What's Next?
- Your event has been submitted for review. Upon approval, your event will be published to the Spokes event
- board and made available to the public.
+ {submittedForReview
+ ? "Your event has been submitted for review. Upon approval, your event will be published to the Spokes event board and made available to the public."
+ : "Your event has been updated."}
-
- Create New Event
+
+ Create Another Event
router.push("/events/manage")}
diff --git a/src/components/events/pages/AdminEventsPage.tsx b/src/components/events/pages/AdminEventsPage.tsx
index ba75698..195e76c 100644
--- a/src/components/events/pages/AdminEventsPage.tsx
+++ b/src/components/events/pages/AdminEventsPage.tsx
@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
-import ChakraCarousel from "@/components/ChakraCarousel/carousel";
import AdminEventCard from "@/components/events/EventCard/AdminEventCard";
import { IEvent } from "@/database/eventSchema";
import { twMerge } from "tailwind-merge";
@@ -125,6 +124,14 @@ export default function AdminEvents() {
setTimeout(() => setIsRefreshing(false), 1000);
};
+ const eventList = (events: IEvent[]) => (
+
+ {events.map((event) => (
+
+ ))}
+
+ );
+
return (
@@ -180,11 +187,7 @@ export default function AdminEvents() {
) : incomingEventData.length === 0 ? (
No pending events
) : (
-
- {incomingEventData.map((event) => (
-
- ))}
-
+ eventList(incomingEventData)
)}
@@ -279,22 +282,14 @@ export default function AdminEvents() {
) : approvedEventData.length === 0 ? (
No approved events
) : (
-
- {approvedEventData.map((event) => (
-
- ))}
-
+ eventList(approvedEventData)
)
) : !rejectedEventData ? (
Loading...
) : rejectedEventData.length === 0 ? (
No rejected events
) : (
-
- {rejectedEventData.map((event) => (
-
- ))}
-
+ eventList(rejectedEventData)
)}
diff --git a/src/components/events/pages/EventsBoardPage.tsx b/src/components/events/pages/EventsBoardPage.tsx
index 23c2934..5db45cf 100644
--- a/src/components/events/pages/EventsBoardPage.tsx
+++ b/src/components/events/pages/EventsBoardPage.tsx
@@ -56,9 +56,14 @@ function filterEvents(
});
}
-function getMonthYearOptions(events: EventRecord[]): string[] {
- const monthYears = new Set(events.map(getEventMonthYearValue));
- return [...monthYears].sort();
+function getMonthYearOptions(): string[] {
+ const now = new Date();
+ // Always offer the current month plus the next twelve months, even when
+ // there are no listings in a particular month yet.
+ return Array.from({ length: 13 }, (_, offset) => {
+ const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 1));
+ return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
+ });
}
export default function EventsPage() {
@@ -122,12 +127,12 @@ export default function EventsPage() {
}, []);
const filteredEvents = useMemo(
- () => filterEvents(events, selectedLocationGeneral, cityFilter, monthYearFilter),
- [events, selectedLocationGeneral, cityFilter, monthYearFilter],
+ () => filterEvents(events, selectedLocationGeneral, cityFilter, activeTab === "past" ? "" : monthYearFilter),
+ [activeTab, events, selectedLocationGeneral, cityFilter, monthYearFilter],
);
const recentEventIdSet = useMemo(() => new Set(recentEventIds), [recentEventIds]);
- const monthYearOptions = useMemo(() => getMonthYearOptions(events), [events]);
+ const monthYearOptions = useMemo(() => getMonthYearOptions(), []);
const displayedEvents = useMemo(() => {
const now = new Date();
@@ -233,11 +238,13 @@ export default function EventsPage() {
setMonthYearFilter(event.target.value)}
+ disabled={activeTab === "past"}
onFocus={() => setMonthSelectOpen(true)}
onBlur={() => setMonthSelectOpen(false)}
className={twMerge(
"h-10 w-full rounded-md px-3 text-sm text-black bg-[#F7F7F7] outline-none appearance-none cursor-pointer transition-colors",
monthSelectOpen ? "bg-gray-200" : "hover:bg-gray-200",
+ activeTab === "past" && "cursor-not-allowed opacity-50",
)}
>
@@ -331,6 +338,7 @@ export default function EventsPage() {
)}
onClick={() => {
setActiveTab("past");
+ setMonthYearFilter("");
setSortAscending(true);
}}
>
diff --git a/src/components/events/pages/ListEventPage.tsx b/src/components/events/pages/ListEventPage.tsx
index e3ebdce..a8c84b2 100644
--- a/src/components/events/pages/ListEventPage.tsx
+++ b/src/components/events/pages/ListEventPage.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@clerk/nextjs";
+import Link from "next/link";
import {
Box,
Button,
@@ -17,6 +18,14 @@ import {
VStack,
Spinner,
Center,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ useToast,
} from "@chakra-ui/react";
import { IEvent } from "@/database/eventSchema";
import EventConfirmationModal from "@/components/events/EventModals/EventConfirmationModal";
@@ -36,6 +45,8 @@ const ensureHttps = (url: string | undefined): string | undefined => {
function EventFormContent() {
const searchParams = useSearchParams();
+ const router = useRouter();
+ const toast = useToast();
const { isSignedIn, orgSlug } = useAuth();
const isSpokesAdmin = orgSlug === "spokes-admin";
const [serverError, setServerError] = useState(null);
@@ -49,13 +60,26 @@ function EventFormContent() {
const [isFailModalOpen, setIsFailModalOpen] = useState(false);
const [createForAnotherOrganization, setCreateForAnotherOrganization] = useState(false);
const [selectedAdminOrganizationName, setSelectedAdminOrganizationName] = useState("");
+ const [formVersion, setFormVersion] = useState(0);
+ const [isDeleteConfirmationOpen, setIsDeleteConfirmationOpen] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isRejectConfirmationOpen, setIsRejectConfirmationOpen] = useState(false);
+ const [isRejecting, setIsRejecting] = useState(false);
+ const [rejectionReason, setRejectionReason] = useState("");
const eventId = searchParams.get("eventId");
+ const returnURL = searchParams.get("returnURL") || "/events/manage";
const eventInfoLink = getEventInfoLink(eventData);
const eventLocationLink = getEventLocationLink(eventData);
// Load existing event if editing
useEffect(() => {
- if (!eventId) return;
+ if (!eventId) {
+ setEventData(null);
+ setMajorFundraisingEvent(false);
+ setEventLocationGeneral("");
+ setEventLocationCity("");
+ return;
+ }
const fetchEvent = async () => {
setIsLoadingEvent(true);
@@ -77,6 +101,19 @@ function EventFormContent() {
fetchEvent();
}, [eventId]);
+ const createAnotherEvent = () => {
+ setIsConfirmationModalOpen(false);
+ setServerError(null);
+ setEventData(null);
+ setMajorFundraisingEvent(false);
+ setEventLocationGeneral("");
+ setEventLocationCity("");
+ setCreateForAnotherOrganization(false);
+ setSelectedAdminOrganizationName("");
+ setFormVersion((current) => current + 1);
+ router.push("/events/list");
+ };
+
const sendNewEventEmail = async (event: IEvent) => {
try {
await fetch("/api/send/event-new", {
@@ -89,6 +126,78 @@ function EventFormContent() {
}
};
+ const deleteEvent = async () => {
+ if (!eventId || isDeleting) return;
+
+ setIsDeleting(true);
+ try {
+ const response = await fetch(`/api/events/${eventId}`, { method: "DELETE" });
+ if (!response.ok) {
+ const result = await response.json().catch(() => null);
+ throw new Error(result?.message || "Failed to delete event");
+ }
+
+ toast({
+ title: "Event Deleted",
+ description: "The event has been removed.",
+ status: "success",
+ duration: 5000,
+ isClosable: true,
+ position: "top-right",
+ });
+ router.push(returnURL);
+ } catch (error) {
+ console.error("Error deleting event:", error);
+ setServerError(error instanceof Error ? error.message : "Failed to delete event.");
+ setIsDeleteConfirmationOpen(false);
+ setIsFailModalOpen(true);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const rejectEvent = async () => {
+ if (!eventId || isRejecting) return;
+
+ setIsRejecting(true);
+ try {
+ const response = await fetch(`/api/events/${eventId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ eventStatus: "rejected", rejectionMessage: rejectionReason }),
+ });
+ if (!response.ok) {
+ const result = await response.json().catch(() => null);
+ throw new Error(result?.message || "Failed to reject event");
+ }
+
+ if (eventData) {
+ await fetch("/api/send/event-reject", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...eventData, rejectionReason }),
+ });
+ }
+
+ toast({
+ title: "Event Rejected",
+ description: `Successfully rejected \"${eventData?.eventName ?? "event"}\"`,
+ status: "warning",
+ duration: 5000,
+ isClosable: true,
+ position: "top-right",
+ });
+ router.push(returnURL);
+ } catch (error) {
+ console.error("Error rejecting event:", error);
+ setServerError(error instanceof Error ? error.message : "Failed to reject event.");
+ setIsRejectConfirmationOpen(false);
+ setIsFailModalOpen(true);
+ } finally {
+ setIsRejecting(false);
+ }
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
@@ -200,7 +309,7 @@ function EventFormContent() {
{serverError}
)}
-
- setIsConfirmationModalOpen(false)} />
+ setIsConfirmationModalOpen(false)}
+ onCreateAnother={createAnotherEvent}
+ submittedForReview={!eventId || !isSpokesAdmin}
+ />
setIsFailModalOpen(false)} />
+ setIsDeleteConfirmationOpen(false)} isCentered>
+
+
+ Confirm Delete
+
+ Are you sure you would like to delete this event?
+
+
+ Delete
+
+ setIsDeleteConfirmationOpen(false)}
+ isDisabled={isDeleting}
+ _hover={{ backgroundColor: "gray.200" }}
+ >
+ Cancel
+
+
+
+
+ setIsRejectConfirmationOpen(false)} isCentered>
+
+
+ Confirm Rejection
+
+
+ Are you sure you would like to reject this event?
+
+
+
+ Reject
+
+ setIsRejectConfirmationOpen(false)}
+ isDisabled={isRejecting}
+ _hover={{ backgroundColor: "gray.200" }}
+ >
+ Cancel
+
+
+
+
>
);
}
diff --git a/src/components/events/pages/ManageEventsPage.tsx b/src/components/events/pages/ManageEventsPage.tsx
index 37244dd..025eebc 100644
--- a/src/components/events/pages/ManageEventsPage.tsx
+++ b/src/components/events/pages/ManageEventsPage.tsx
@@ -123,7 +123,7 @@ export default function EventsDashboardPage() {
Live Events
{liveEvents.length > 0 ? (
- liveEvents.map((event, index) =>
)
+ liveEvents.map((event) =>
)
) : (
No live events available
)}
@@ -133,7 +133,7 @@ export default function EventsDashboardPage() {
Pending Events
{pendingEvents.length > 0 ? (
- pendingEvents.map((event, index) =>
)
+ pendingEvents.map((event) =>
)
) : (
No pending events available
)}
diff --git a/src/components/jobs/JobCard/AdminCard.tsx b/src/components/jobs/JobCard/AdminCard.tsx
index 3e5131e..07bb400 100644
--- a/src/components/jobs/JobCard/AdminCard.tsx
+++ b/src/components/jobs/JobCard/AdminCard.tsx
@@ -135,7 +135,7 @@ export default function AdminCard({ job, onUpdateJob, innerRef }: JobCardProps)
}
}
- const isActuallyExpired = isExpired(job.jobStatus, job.approvedDate);
+ const isActuallyExpired = isExpired(job.jobStatus);
function handleEditApplicationButton(e: React.ChangeEvent
) {
e.preventDefault();
diff --git a/src/components/jobs/JobCard/JobDateInfo.tsx b/src/components/jobs/JobCard/JobDateInfo.tsx
index 53f1c4f..31c3524 100644
--- a/src/components/jobs/JobCard/JobDateInfo.tsx
+++ b/src/components/jobs/JobCard/JobDateInfo.tsx
@@ -12,7 +12,7 @@ function formatDate(date: Date | undefined) {
return new Date(date).toLocaleDateString(undefined, options);
}
-export type JobDateKind = "submitted" | "posted" | "updated" | "expires" | "expired";
+export type JobDateKind = "submitted" | "posted" | "updated" | "resolved";
export default function JobDateInfo({
date,
@@ -35,11 +35,8 @@ export default function JobDateInfo({
case "updated":
label = "Updated: ";
break;
- case "expires":
- label = "Expires: ";
- break;
- case "expired":
- label = "Expired: ";
+ case "resolved":
+ label = "Resolved: ";
break;
}
diff --git a/src/components/jobs/JobCard/JobStatusBadge.tsx b/src/components/jobs/JobCard/JobStatusBadge.tsx
index 004076a..a9e4400 100644
--- a/src/components/jobs/JobCard/JobStatusBadge.tsx
+++ b/src/components/jobs/JobCard/JobStatusBadge.tsx
@@ -28,7 +28,7 @@ export default function JobStatusBadge({ jobStatus, className }: JobStatusBadgeP
case "expired":
badgeColor = "#FFE5E5"; // red
textColor = "#D45959";
- tooltipText = "This job posting is no longer active. Renew to make it visible to potential applicants again.";
+ tooltipText = "This job listing has been resolved and is no longer visible to potential applicants.";
break;
case "rejected":
badgeColor = "#FFE5E5"; // red
@@ -42,7 +42,7 @@ export default function JobStatusBadge({ jobStatus, className }: JobStatusBadgeP
break;
}
- const badgeName = formatBadgeName(jobStatus.toString());
+ const badgeName = jobStatus === "expired" ? "Resolved" : formatBadgeName(jobStatus.toString());
return (
(
const { isOpen, onOpen, onClose } = useDisclosure();
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [actionText, setActionText] = useState("");
- const isActuallyExpired = isExpired(job.jobStatus, job.approvedDate);
+ const isActuallyExpired = isExpired(job.jobStatus);
const router = useRouter();
const toast = useToast();
@@ -89,8 +83,8 @@ export const OrgCard = forwardRef(
// Show success toast
toast({
- title: "Job Renewed",
- description: `Successfully renewed "${job.title}"`,
+ title: "Job Reopened",
+ description: `Successfully reopened "${job.title}"`,
status: "success",
duration: 5000,
isClosable: true,
@@ -108,7 +102,7 @@ export const OrgCard = forwardRef(
console.error("Error renewing job:", error);
toast({
title: "Error",
- description: "Failed to renew job. Please try again.",
+ description: "Failed to reopen job. Please try again.",
status: "error",
duration: 5000,
isClosable: true,
@@ -117,12 +111,12 @@ export const OrgCard = forwardRef(
}
}
- function handleConfirmationModal(action: "unpublish" | "renew") {
- setActionText(action === "unpublish" ? "Unpublish" : "Renew");
+ function handleConfirmationModal(action: "resolve" | "renew") {
+ setActionText(action === "resolve" ? "Resolve" : "Reopen");
setIsConfirmationModalOpen(true);
}
- async function handleUnpublishJob() {
+ async function handleResolveJob() {
try {
const response = await fetch(`/api/jobs/${job._id}`, {
method: "PUT",
@@ -130,20 +124,20 @@ export const OrgCard = forwardRef(
"Content-Type": "application/json",
},
body: JSON.stringify({
- isUnpublish: true,
+ isResolve: true,
previousStatus: job.jobStatus,
newStatus: "expired",
}),
});
if (!response.ok) {
- throw new Error("Failed to unpublish job");
+ throw new Error("Failed to resolve job");
}
const data = await response.json();
// Show success toast
toast({
- title: "Job Unpublished",
- description: `Successfully unpublished "${job.title}"`,
+ title: "Job Resolved",
+ description: `Successfully resolved "${job.title}"`,
status: "success",
duration: 5000,
isClosable: true,
@@ -158,10 +152,10 @@ export const OrgCard = forwardRef(
window.location.reload();
}
} catch (error) {
- console.error("Error unpublishing job:", error);
+ console.error("Error resolving job:", error);
toast({
title: "Error",
- description: "Failed to unpublish job. Please try again.",
+ description: "Failed to resolve job. Please try again.",
status: "error",
duration: 5000,
isClosable: true,
@@ -223,12 +217,12 @@ export const OrgCard = forwardRef(
variant="outline"
colorScheme="blue"
size={{ base: "xs", md: "sm" }}
- onClick={() => handleConfirmationModal("unpublish")}
+ onClick={() => handleConfirmationModal("resolve")}
className="flex flex-row items-center gap-1 sm:gap-2"
>
- Unpublish Job
- Unpublish
+ Resolve Job
+ Resolve
)}
@@ -256,17 +250,17 @@ export const OrgCard = forwardRef
(
variant="outline"
colorScheme="blue"
size={{ base: "xs", md: "sm" }}
- onClick={() => handleConfirmationModal("unpublish")}
+ onClick={() => handleConfirmationModal("resolve")}
className="hidden md:flex flex-row items-center gap-1 sm:gap-2"
>
- Unpublish Job
+ Resolve Job
)}
{isActuallyExpired && (
(
className="flex flex-row items-center gap-1 sm:gap-2"
>
- Renew
+ Reopen
)}
(
Are you sure you would like to {actionText.toLowerCase()} this job posting?
- {actionText == "Unpublish" && " Unpublishing a job will mark it as expired, you can renew it later"}
+ {actionText == "Resolve" && " Resolving a job will remove it from the public job board."}
(
fontWeight="normal"
borderColor="black"
onClick={() => {
- if (actionText === "Unpublish") {
- handleUnpublishJob();
- } else if (actionText === "Renew") {
+ if (actionText === "Resolve") {
+ handleResolveJob();
+ } else if (actionText === "Reopen") {
handleRenewJob();
}
setIsConfirmationModalOpen(false);
diff --git a/src/components/jobs/JobGrid/JobGrid.tsx b/src/components/jobs/JobGrid/JobGrid.tsx
index 3a5805f..52195e5 100644
--- a/src/components/jobs/JobGrid/JobGrid.tsx
+++ b/src/components/jobs/JobGrid/JobGrid.tsx
@@ -78,8 +78,8 @@ function NoJobsFound({ isPending, isJobBoard, isLive, isExpired, isRejected }: N
}
if (isExpired) {
return {
- title: "No Expired Jobs",
- message: "There are no expired job listings. Jobs will automatically move here after 30 days.",
+ title: "No Resolved Jobs",
+ message: "There are no resolved job listings.",
};
}
if (isRejected) {
diff --git a/src/components/jobs/pages/JobsAdminPage.tsx b/src/components/jobs/pages/JobsAdminPage.tsx
index 33c6774..aa3d79c 100644
--- a/src/components/jobs/pages/JobsAdminPage.tsx
+++ b/src/components/jobs/pages/JobsAdminPage.tsx
@@ -7,7 +7,6 @@ import { IJob } from "@/database/jobSchema";
import { twMerge } from "tailwind-merge";
import JobGridSkeleton from "@/components/jobs/JobGrid/JobGridSkeleton";
import Link from "next/link";
-import { isExpired } from "@/lib/utils";
import { Tooltip, useToast } from "@chakra-ui/react";
export default function AdminJobs() {
@@ -21,63 +20,8 @@ export default function AdminJobs() {
const [lastUpdateTime, setLastUpdateTime] = useState(0);
const fetchDataRef = useRef<() => Promise>(async () => {});
- const repairMissingApprovalDates = async (jobs: IJob[]) => {
- const jobsMissingApprovalDate = jobs.filter((job) => job.jobStatus === "approved" && !job.approvedDate);
-
- await Promise.all(
- jobsMissingApprovalDate.map(async (job) => {
- try {
- const response = await fetch(`/api/jobs/${job._id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- previousStatus: "approved",
- newStatus: "approved",
- }),
- });
-
- if (!response.ok) {
- console.error(`Failed to repair approved date for job ${job._id}`);
- }
- } catch (error) {
- console.error(`Failed to repair approved date for job ${job._id}:`, error);
- }
- }),
- );
- };
-
- const setExpiredJobs = async (jobs: IJob[]) => {
- if (!Array.isArray(jobs)) {
- console.error("Expected jobs to be an array but received:", typeof jobs);
- return;
- }
-
- // Repair approved jobs created before the API started persisting
- // approvedDate. The API assigns the timestamp on this approved ->
- // approved update, so the public listing can use its normal 30-day rule.
- await repairMissingApprovalDates(jobs);
-
- const jobsToExpire = jobs.filter(
- (job) => job.jobStatus !== "expired" && isExpired(job.jobStatus, job.approvedDate),
- );
-
- for (const job of jobsToExpire) {
- await updateJobStatus(job._id, "expired");
- }
- };
-
const fetchData = async () => {
try {
- const response = await fetch("/api/jobs?jobStatus=approved&admin=true");
- if (!response.ok) {
- console.error("Failed to fetch jobs:", response.status, response.statusText);
- return;
- }
- const result: IJob[] = await response.json();
-
- // Check and update expired jobs
- await setExpiredJobs(result);
-
// Fetch all job statuses in parallel
const jobStatuses = ["pending", "approved", "rejected", "expired"];
const responses = await Promise.all(
@@ -109,7 +53,7 @@ export default function AdminJobs() {
useEffect(() => {
fetchDataRef.current();
- // Set up an interval to check for expired jobs every hour
+ // Refresh job data every hour while the dashboard is open.
const interval = setInterval(() => fetchDataRef.current(), 3600000);
// Cleanup interval on component unmount
@@ -200,8 +144,8 @@ export default function AdminJobs() {
setRejectedJobData((prev) => [...(prev ?? []), updatedJob]);
} else if (status === "expired") {
toast({
- title: "Job Expired",
- description: `"${currentJob.title}" has been marked as expired`,
+ title: "Job Resolved",
+ description: `"${currentJob.title}" has been resolved`,
status: "warning",
duration: 5000,
isClosable: true,
@@ -339,8 +283,8 @@ export default function AdminJobs() {
setTab(2);
}}
>
- Expired Jobs
- Expired
+ Resolved Jobs
+ Resolved
(
{
date: { type: Date, required: true },
eventName: { type: String, required: true, trim: true },
- time: { type: String, required: true, trim: true },
- location: { type: String, required: true, trim: true },
+ time: { type: String, required: false, trim: true, default: "" },
+ location: { type: String, required: false, trim: true, default: "" },
locationLink: { type: String, required: false, trim: true },
eventLocationGeneral: {
type: String,
diff --git a/src/lib/events.ts b/src/lib/events.ts
index ecfd7b5..61fcc23 100644
--- a/src/lib/events.ts
+++ b/src/lib/events.ts
@@ -3,10 +3,8 @@ import { isEventLocationCity, isEventLocationGeneral } from "@/lib/eventOptions"
const requiredEventFields = [
"eventName",
"date",
- "time",
"eventLocationGeneral",
"eventLocationCity",
- "location",
"description",
"publicContactEmail",
"publicContactPhoneNumber",
@@ -18,6 +16,8 @@ const requiredEventFields = [
const mutableEventFields = [
...requiredEventFields,
+ "time",
+ "location",
"eventLink",
"locationLink",
"eventLocationGeneralOther",
@@ -27,6 +27,7 @@ const mutableEventFields = [
] as const;
const booleanEventFields = ["majorFundraisingEvent"] as const;
+const optionalTextEventFields = ["time", "location"] as const;
function ensureHttps(url: string) {
return url.startsWith("http://") || url.startsWith("https://") ? url : `https://${url}`;
@@ -40,7 +41,7 @@ export function sanitizeEventPayload(payload: Record) {
if (typeof value === "string") {
const trimmedValue = value.trim();
- if (trimmedValue) {
+ if (trimmedValue || optionalTextEventFields.includes(field as (typeof optionalTextEventFields)[number])) {
sanitized[field] = field === "eventLink" || field === "locationLink" ? ensureHttps(trimmedValue) : trimmedValue;
}
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index a691c09..7bcda12 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -36,13 +36,8 @@ export function timeAgo(date: Date): string {
return formatTime(years, "year") + " ago";
}
-export function getThirtyDaysAgo(): Date {
- return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
-}
-
-export function isExpired(jobStatus: string, approvedDate?: Date): boolean {
- if (!approvedDate) return false;
- return new Date(approvedDate) < getThirtyDaysAgo() || jobStatus.toLowerCase() === "expired";
+export function isExpired(jobStatus: string): boolean {
+ return jobStatus.toLowerCase() === "expired";
}
function toDate(input: string | Date | undefined): Date | undefined {