Skip to content

Commit ddcea40

Browse files
5queezerclaude
andauthored
Add bulk archive feature for old applications (#88)
* Add one-click bulk archive for old applications Adds an "Archive old" dropdown button to the dashboard toolbar that lets users archive all applications older than a selectable threshold (30, 60, 90, or 180 days). Each option shows a live count badge of matching apps. Includes confirmation dialog before archiving and i18n support (EN/DE). https://claude.ai/code/session_01XjeBMDALg8J9i3DxWzxrkv * Fix variable ordering and use onSettled for bulk archive mutation Move handleBulkArchive below activeApplications declaration to fix reference-before-definition. Use onSettled instead of onSuccess so query invalidation runs even if some archive requests fail. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e990575 commit ddcea40

3 files changed

Lines changed: 119 additions & 3 deletions

File tree

components/dashboard.tsx

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4-
import { useEffect, useState } from "react";
4+
import { useCallback, useEffect, useRef, useState } from "react";
55
import { useTranslations } from "next-intl";
66
import { authClient } from "@/lib/auth-client";
77
import { ApplicationTable } from "./application-table";
@@ -154,11 +154,33 @@ export function Dashboard({ user, shareUrl, initialStatus, initialSource, initia
154154
archiveMutation.mutate({ id, archive });
155155
}
156156

157+
const bulkArchiveMutation = useMutation({
158+
mutationFn: async (ids: string[]) => {
159+
await Promise.all(ids.map((id) => archiveApplication(id, true)));
160+
},
161+
onSettled: () => {
162+
queryClient.invalidateQueries({ queryKey: ["applications"] });
163+
},
164+
});
165+
157166
// Filter by archive status
158167
const activeApplications = applications.filter((a) => !a.archivedAt);
159168
const archivedApplications = applications.filter((a) => !!a.archivedAt);
160169
const visibleApplications = showArchived ? archivedApplications : activeApplications;
161170

171+
function handleBulkArchive(days: number) {
172+
const cutoff = new Date();
173+
cutoff.setDate(cutoff.getDate() - days);
174+
const old = activeApplications.filter((a) => {
175+
const d = a.appliedAt ? new Date(a.appliedAt) : new Date(a.createdAt);
176+
return d < cutoff;
177+
});
178+
if (old.length === 0) return;
179+
if (confirm(ta("archive_old_confirm", { count: old.length, days }))) {
180+
bulkArchiveMutation.mutate(old.map((a) => a.id));
181+
}
182+
}
183+
162184
const stats = {
163185
total: activeApplications.length,
164186
inbound: activeApplications.filter((a) => a.status === "inbound").length,
@@ -433,6 +455,8 @@ export function Dashboard({ user, shareUrl, initialStatus, initialSource, initia
433455
)}
434456
</button>
435457

458+
{!showArchived && <ArchiveOldDropdown applications={activeApplications} onArchive={handleBulkArchive} isPending={bulkArchiveMutation.isPending} />}
459+
436460
<button
437461
onClick={() => exportToCsv(visibleApplications)}
438462
title={ta("export_csv")}
@@ -484,6 +508,90 @@ export function Dashboard({ user, shareUrl, initialStatus, initialSource, initia
484508
);
485509
}
486510

511+
const ARCHIVE_THRESHOLDS = [30, 60, 90, 180] as const;
512+
513+
function ArchiveOldDropdown({
514+
applications,
515+
onArchive,
516+
isPending,
517+
}: {
518+
applications: Application[];
519+
onArchive: (days: number) => void;
520+
isPending: boolean;
521+
}) {
522+
const ta = useTranslations("actions");
523+
const [open, setOpen] = useState(false);
524+
const ref = useRef<HTMLDivElement>(null);
525+
526+
const countForDays = useCallback(
527+
(days: number) => {
528+
const cutoff = new Date();
529+
cutoff.setDate(cutoff.getDate() - days);
530+
return applications.filter((a) => {
531+
const d = a.appliedAt ? new Date(a.appliedAt) : new Date(a.createdAt);
532+
return d < cutoff;
533+
}).length;
534+
},
535+
[applications]
536+
);
537+
538+
useEffect(() => {
539+
function handleClickOutside(e: MouseEvent) {
540+
if (ref.current && !ref.current.contains(e.target as Node)) {
541+
setOpen(false);
542+
}
543+
}
544+
if (open) {
545+
document.addEventListener("mousedown", handleClickOutside);
546+
return () => document.removeEventListener("mousedown", handleClickOutside);
547+
}
548+
}, [open]);
549+
550+
return (
551+
<div ref={ref} className="relative w-full sm:w-auto">
552+
<button
553+
onClick={() => setOpen((v) => !v)}
554+
disabled={isPending}
555+
className={`flex w-full items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors sm:w-auto ${
556+
open
557+
? "border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-300"
558+
: "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
559+
} ${isPending ? "opacity-50 cursor-wait" : ""}`}
560+
>
561+
{isPending ? ta("archive_old_archiving") : ta("archive_old")}
562+
<span className="text-xs">{open ? "▲" : "▼"}</span>
563+
</button>
564+
{open && (
565+
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
566+
{ARCHIVE_THRESHOLDS.map((days) => {
567+
const count = countForDays(days);
568+
return (
569+
<button
570+
key={days}
571+
onClick={() => {
572+
setOpen(false);
573+
onArchive(days);
574+
}}
575+
disabled={count === 0}
576+
className="flex w-full items-center justify-between px-4 py-2.5 text-sm text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed first:rounded-t-lg last:rounded-b-lg"
577+
>
578+
<span className="text-gray-700 dark:text-gray-200">
579+
{ta("archive_old_option", { days })}
580+
</span>
581+
{count > 0 && (
582+
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-amber-100 px-1.5 text-xs font-bold text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
583+
{count}
584+
</span>
585+
)}
586+
</button>
587+
);
588+
})}
589+
</div>
590+
)}
591+
</div>
592+
);
593+
}
594+
487595
function StatCard({
488596
label,
489597
value,

messages/de.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
"unarchive": "Wiederherstellen",
3535
"show_archive": "Archiv",
3636
"show_active": "Aktiv",
37-
"remote_only": "Nur Remote"
37+
"remote_only": "Nur Remote",
38+
"archive_old": "Alte archivieren",
39+
"archive_old_option": "Älter als {days} Tage",
40+
"archive_old_confirm": "{count} Opportunities älter als {days} Tage archivieren?",
41+
"archive_old_archiving": "Archiviere..."
3842
},
3943
"table": {
4044
"company": "Kunde",

messages/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
"unarchive": "Unarchive",
3535
"show_archive": "Archive",
3636
"show_active": "Active",
37-
"remote_only": "Remote only"
37+
"remote_only": "Remote only",
38+
"archive_old": "Archive old",
39+
"archive_old_option": "Older than {days} days",
40+
"archive_old_confirm": "Archive {count} opportunities older than {days} days?",
41+
"archive_old_archiving": "Archiving..."
3842
},
3943
"table": {
4044
"company": "Account",

0 commit comments

Comments
 (0)