Skip to content
Merged
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
4 changes: 3 additions & 1 deletion frontend/.prettierrc
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
{}
{
"endOfLine": "auto"
}
9 changes: 8 additions & 1 deletion frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,8 @@ body {
position: relative;
transition:
border-color 0.12s ease,
transform 0.12s ease;
opacity 0.22s ease,
transform 0.22s cubic-bezier(0.2, 0, 0.2, 1);
}
.apps-kanban-card.has-started-action {
padding-right: 30px;
Expand All @@ -817,6 +818,12 @@ body {
.apps-kanban-card.is-dragging {
visibility: hidden;
}
.apps-kanban-card.is-deleting {
opacity: 0;
transform: translate3d(0, -4px, 0) scale(0.985);
pointer-events: none;
will-change: opacity, transform;
}
.apps-started-action {
position: absolute;
top: -1px;
Expand Down
96 changes: 81 additions & 15 deletions frontend/src/app/my-applications/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ type RecruitmentCycleRecord = {
updatedAt: Date;
};

type ApplicationRecord = {
_id: ObjectId;
userId: ObjectId;
jobId: string;
status: ApplicationStatus;
startedAt: Date;
updatedAt: Date;
jobSnapshot: ApplicationJobSnapshot;
cycleId?: string;
notes?: string;
starred?: boolean;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function requireUserId(session: any) {
const id = (session?.user as { id?: string } | undefined)?.id;
Expand All @@ -44,6 +57,26 @@ function serializeCycle(
};
}

function serializeApplication(
doc: ApplicationRecord,
fallbackLogo?: string,
): DbApplication {
return {
_id: doc._id.toString(),
jobId: doc.jobId,
status: doc.status,
startedAt: new Date(doc.startedAt).toISOString(),
updatedAt: new Date(doc.updatedAt).toISOString(),
jobSnapshot: {
...doc.jobSnapshot,
logo: doc.jobSnapshot.logo ?? fallbackLogo,
},
cycleId: doc.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID,
notes: doc.notes ?? undefined,
starred: doc.starred ?? false,
};
}

async function ensureDefaultCycle(db: Db, userObjectId: ObjectId) {
const now = new Date();

Expand Down Expand Up @@ -210,7 +243,7 @@ export async function listApplications(): Promise<DbApplication[]> {
const db = client.db(process.env.MONGODB_DATABASE || "default");

const docs = await db
.collection("applications")
.collection<ApplicationRecord>("applications")
.find({ userId: new ObjectId(userId) })
.sort({ updatedAt: -1 })
.limit(500)
Expand Down Expand Up @@ -242,20 +275,7 @@ export async function listApplications(): Promise<DbApplication[]> {
}
}

return docs.map((d) => ({
_id: d._id.toString(),
jobId: d.jobId,
status: d.status,
startedAt: new Date(d.startedAt).toISOString(),
updatedAt: new Date(d.updatedAt).toISOString(),
jobSnapshot: {
...d.jobSnapshot,
logo: d.jobSnapshot.logo ?? logoMap.get(d.jobId),
},
cycleId: d.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID,
notes: d.notes ?? undefined,
starred: d.starred ?? false,
})) as DbApplication[];
return docs.map((d) => serializeApplication(d, logoMap.get(d.jobId)));
}

export async function addApplication(
Expand Down Expand Up @@ -300,6 +320,52 @@ export async function deleteApplication(jobId: string) {
return { ok: true };
}

export async function restoreDeletedApplication(
application: DbApplication,
): Promise<DbApplication> {
const session = await getServerSession(getAuthOptions());
const userId = requireUserId(session);
const userObjectId = new ObjectId(userId);

const client = await getMongoClientPromise();
const db = client.db(process.env.MONGODB_DATABASE || "default");
const collection = db.collection<ApplicationRecord>("applications");
const startedAt = new Date(application.startedAt);
const updatedAt = new Date(application.updatedAt);
const hasNotes =
typeof application.notes === "string" &&
application.notes.trim().length > 0;

await collection.updateOne(
{ userId: userObjectId, jobId: application.jobId },
{
$set: {
jobId: application.jobId,
status: application.status,
startedAt,
updatedAt,
jobSnapshot: application.jobSnapshot,
cycleId: application.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID,
starred: application.starred ?? false,
...(hasNotes ? { notes: application.notes } : {}),
},
...(hasNotes ? {} : { $unset: { notes: "" } }),
$setOnInsert: {
userId: userObjectId,
},
},
{ upsert: true },
);

const restored = await collection.findOne({
userId: userObjectId,
jobId: application.jobId,
});

if (!restored) throw new Error("Application could not be restored");
return serializeApplication(restored);
}

export async function createCustomApplication(
title: string,
companyName: string,
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/components/applications/applications-kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ type Props = {
oldStatus: ApplicationStatus,
next: ApplicationStatus,
) => Promise<void>;
onDelete: (appId: string, jobId: string) => void;
onDelete: (appId: string, jobId: string) => Promise<void>;
onSaveNotes: (jobId: string, notes: string) => Promise<void>;
onCreateInStage: (
title: string,
Expand Down Expand Up @@ -934,9 +934,18 @@ function KanbanCard({
);
const [movingTo, setMovingTo] = useState<string | null>(null);
const [moveFeedback, setMoveFeedback] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const mountedRef = useRef(true);
const currentStage = stages.find((stage) => stage.name === app.status);
const moveTargets = stages.filter((stage) => stage.name !== app.status);

useEffect(
() => () => {
mountedRef.current = false;
},
[],
);

const setCardNodeRef = useCallback(
(node: HTMLElement | null) => {
cardRef.current = node;
Expand Down Expand Up @@ -1164,7 +1173,8 @@ function KanbanCard({
(app.status === "STARTED" ? " has-started-action" : "") +
(density === "compact" ? " apps-kanban-card--compact" : "") +
(!mobileDragDisabled && isDragging ? " is-dragging" : "") +
(mobileDragDisabled ? " is-mobile-drag-disabled" : "")
(mobileDragDisabled ? " is-mobile-drag-disabled" : "") +
(isDeleting ? " is-deleting" : "")
}
style={style}
onClick={() => url && window.open(url, "_blank", "noreferrer")}
Expand Down Expand Up @@ -1267,9 +1277,15 @@ function KanbanCard({
type="button"
className="apps-icon-btn"
aria-label="Delete"
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation();
onDelete(app._id, app.jobId);
if (isDeleting) return;
setNotesOpen(false);
setIsDeleting(true);
void onDelete(app._id, app.jobId).catch(() => {
if (mountedRef.current) setIsDeleting(false);
});
}}
>
<IconTrash size={14} />
Expand Down
105 changes: 101 additions & 4 deletions frontend/src/components/applications/my-applications-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
IconSearch,
IconTrash,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import Link from "next/link";
import {
ApplicationStatus,
Expand All @@ -47,6 +48,7 @@ import {
deleteRecruitmentCycle,
deleteApplication,
renameRecruitmentCycle,
restoreDeletedApplication,
syncLocalApplications,
toggleApplicationStar,
updateApplicationNotes,
Expand All @@ -66,6 +68,7 @@ const SORT_STORAGE_KEY = "mp:apps:kanban-sort:v1";
const DENSITY_STORAGE_KEY = "mp:apps:kanban-density:v1";
const STAGE_ORDER_STORAGE_KEY = "mp:apps:stage-order:v1";
const MAC_YELLOW = "#ffe22f";
const DELETE_CARD_FADE_MS = 220;

function readSort(): KanbanSort {
if (typeof window === "undefined") return "newest";
Expand Down Expand Up @@ -309,11 +312,105 @@ export default function MyApplicationsClient({

async function handleDelete(appId: string, jobId: string) {
const removed = apps.find((a) => a._id === appId);
setApps((prev) => prev.filter((a) => a._id !== appId));
if (!removed) throw new Error("Application not found");

const fadePromise = new Promise<"fade">((resolve) => {
window.setTimeout(() => resolve("fade"), DELETE_CARD_FADE_MS);
});
const deleteResultPromise = deleteApplication(jobId).then(
() => ({ status: "deleted" as const }),
(error) => ({ status: "failed" as const, error }),
);

try {
await deleteApplication(jobId);
} catch {
if (removed) setApps((prev) => [removed, ...prev]);
const firstResult = await Promise.race([
fadePromise,
deleteResultPromise,
]);

if (firstResult !== "fade" && firstResult.status === "failed") {
throw firstResult.error;
}

await fadePromise;
setApps((prev) => prev.filter((a) => a._id !== appId));

const deleteResult =
firstResult === "fade" ? await deleteResultPromise : firstResult;

if (deleteResult.status === "failed") {
setApps((prev) =>
prev.some((app) => app._id === removed._id)
? prev
: [removed, ...prev],
);
throw deleteResult.error;
}

const notificationId = `deleted-application-${removed._id}`;
notifications.show({
id: notificationId,
position: "top-right",
autoClose: 7000,
withCloseButton: true,
color: "accent",
message: (
<Group gap="sm" justify="space-between" wrap="nowrap">
<Text size="sm" c="white" lineClamp={1}>
Deleted{" "}
<span style={{ color: MAC_YELLOW, fontWeight: 700 }}>
{removed.jobSnapshot.title}
</span>{" "}
at{" "}
<span style={{ color: MAC_YELLOW, fontWeight: 700 }}>
{removed.jobSnapshot.companyName}
</span>
</Text>
<Button
size="xs"
variant="subtle"
color="accent"
onClick={async () => {
notifications.hide(notificationId);
setApps((prev) =>
prev.some((app) => app.jobId === removed.jobId)
? prev
: [removed, ...prev],
);

try {
const restored = await restoreDeletedApplication(removed);
setApps((prev) =>
prev.map((app) =>
app.jobId === restored.jobId ? restored : app,
),
);
} catch {
setApps((prev) =>
prev.filter((app) => app.jobId !== removed.jobId),
);
notifications.show({
position: "top-right",
autoClose: 3000,
color: "red",
message: "Couldn't undo delete. Try refreshing.",
});
}
}}
>
Undo
</Button>
</Group>
),
});
} catch (error) {
notifications.show({
position: "top-right",
autoClose: 3000,
color: "red",
message: "Couldn't delete application. Try again.",
});
throw error;
}
}

Expand Down
Loading