Skip to content

Commit 8984aef

Browse files
authored
[#397] Kanban Board (#436)
1 parent 7adefc6 commit 8984aef

3 files changed

Lines changed: 332 additions & 1 deletion

File tree

apps/blade/src/app/_components/admin/club/members/scanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ const ScannerPopUp = ({ eventType }: { eventType: "Member" | "Hacker" }) => {
239239
constraints={{ facingMode: "environment" }}
240240
formats={["qr_code"]}
241241
onScan={async (detectedCodes) => {
242-
const result = detectedCodes[0];
242+
const result = (detectedCodes as { rawValue: string }[])[0];
243243
if (!result) return;
244244
if (scanningRef.current) return;
245245
scanningRef.current = true;
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"use client";
2+
3+
import { useMemo, useState } from "react";
4+
import Link from "next/link";
5+
import {
6+
Calendar,
7+
CheckCircle2,
8+
CircleDot,
9+
Pencil,
10+
SlidersHorizontal,
11+
Users,
12+
} from "lucide-react";
13+
14+
import { ISSUE } from "@forge/consts";
15+
import { Button } from "@forge/ui/button";
16+
import { toast } from "@forge/ui/toast";
17+
18+
import { CreateEditDialog } from "~/app/_components/issues/create-edit-dialog";
19+
import { IssueFetcherPane } from "~/app/_components/issues/issue-fetcher-pane";
20+
import IssueTemplateDialog from "~/app/_components/issues/issue-template-dialog";
21+
import { api } from "~/trpc/react";
22+
23+
const STATUS_COLORS: Record<string, string> = {
24+
BACKLOG: "bg-slate-400",
25+
PLANNING: "bg-amber-400",
26+
IN_PROGRESS: "bg-emerald-400",
27+
FINISHED: "bg-rose-400",
28+
};
29+
30+
function formatStatus(status: string) {
31+
return status
32+
.toLowerCase()
33+
.replace(/_/g, " ")
34+
.replace(/\b\w/g, (c) => c.toUpperCase());
35+
}
36+
37+
function formatDate(value: Date | null) {
38+
if (!value) return "No due date";
39+
return new Date(value).toLocaleDateString();
40+
}
41+
42+
export function KanbanBoard() {
43+
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
44+
const [paneData, setPaneData] = useState<ISSUE.IssueFetcherPaneData | null>(
45+
null,
46+
);
47+
48+
const [statusOverrides, setStatusOverrides] = useState<
49+
Record<string, (typeof ISSUE.ISSUE_STATUS)[number]>
50+
>({});
51+
52+
const utils = api.useUtils();
53+
const deleteIssueMutation = api.issues.deleteIssue.useMutation({
54+
onSuccess: async () => {
55+
await utils.issues.invalidate();
56+
paneData?.refresh();
57+
toast.success("Issue deleted successfully");
58+
},
59+
});
60+
61+
const updateIssueMutation = api.issues.updateIssue.useMutation({
62+
onSuccess: async () => {
63+
await utils.issues.invalidate();
64+
paneData?.refresh();
65+
},
66+
onError: () => toast.error("Failed to update issue status"),
67+
});
68+
69+
const issues = paneData?.issues
70+
? paneData.issues.map((issue) => ({
71+
...issue,
72+
status: statusOverrides[issue.id] ?? issue.status,
73+
}))
74+
: [];
75+
76+
const isLoading = paneData?.isLoading ?? true;
77+
78+
// --- Header Stats Logic ---
79+
const openCount = useMemo(
80+
() => issues.filter((issue) => issue.status !== "FINISHED").length,
81+
[issues],
82+
);
83+
const closedCount = issues.length - openCount;
84+
85+
// --- Active Filters Logic ---
86+
const filters = paneData?.filters;
87+
const activeFilters = useMemo(() => {
88+
if (!filters) return [];
89+
const tags: string[] = [];
90+
if (filters.statusFilter !== "all")
91+
tags.push(formatStatus(filters.statusFilter));
92+
if (filters.teamFilter !== "all") tags.push("Team selected");
93+
if (filters.issueKind !== "all")
94+
tags.push(
95+
filters.issueKind === "task" ? "Tasks only" : "Event-linked only",
96+
);
97+
if (filters.rootOnly) tags.push("Root");
98+
if (filters.dateFrom) tags.push("From " + filters.dateFrom);
99+
if (filters.dateTo) tags.push("To " + filters.dateTo);
100+
if (filters.searchTerm.trim())
101+
tags.push('Search "' + filters.searchTerm.trim() + '"');
102+
return tags;
103+
}, [filters]);
104+
105+
const handleDragStart = (
106+
e: React.DragEvent<HTMLDivElement>,
107+
issueId: string,
108+
) => {
109+
e.dataTransfer.setData("issueId", issueId);
110+
e.dataTransfer.effectAllowed = "move";
111+
};
112+
113+
const handleDrop = (
114+
e: React.DragEvent<HTMLDivElement>,
115+
newStatus: (typeof ISSUE.ISSUE_STATUS)[number],
116+
) => {
117+
e.preventDefault();
118+
const issueId = e.dataTransfer.getData("issueId");
119+
if (!issueId) return;
120+
121+
const issue = issues.find((i) => i.id === issueId);
122+
if (!issue || issue.status === newStatus) return;
123+
124+
const previousStatus = issue.status;
125+
126+
setStatusOverrides((prev) => ({ ...prev, [issueId]: newStatus }));
127+
128+
updateIssueMutation.mutate(
129+
{ id: issueId, status: newStatus },
130+
{
131+
onError: () => {
132+
setStatusOverrides((prev) => ({
133+
...prev,
134+
[issueId]: previousStatus,
135+
}));
136+
},
137+
onSettled: () => {
138+
setStatusOverrides((prev) => {
139+
const copy = { ...prev };
140+
delete copy[issueId];
141+
return copy;
142+
});
143+
},
144+
},
145+
);
146+
};
147+
148+
return (
149+
<section className="mx-auto w-full max-w-7xl space-y-4 py-4">
150+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
151+
<div className="flex items-center gap-4 rounded-md border bg-muted/20 px-3 py-2">
152+
<div className="flex items-center gap-2 text-sm font-medium">
153+
<CircleDot className="h-4 w-4 text-emerald-500" />
154+
<span>{openCount} Open</span>
155+
</div>
156+
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
157+
<CheckCircle2 className="h-4 w-4" />
158+
<span>{closedCount} Closed</span>
159+
</div>
160+
</div>
161+
162+
{/* Action Buttons */}
163+
<div className="flex flex-wrap items-center gap-2">
164+
<CreateEditDialog intent="create">
165+
<Button>Create issue</Button>
166+
</CreateEditDialog>
167+
<IssueTemplateDialog />
168+
<Button variant="outline" onClick={() => setIsFiltersOpen(true)}>
169+
<SlidersHorizontal className="mr-2 h-4 w-4" />
170+
Filters
171+
</Button>
172+
</div>
173+
</div>
174+
175+
{/* 2. Active Filter Tags */}
176+
{activeFilters.length > 0 && (
177+
<div className="flex flex-wrap gap-2">
178+
{activeFilters.map((tag) => (
179+
<span
180+
key={tag}
181+
className="rounded-full border bg-background px-2.5 py-1 text-xs text-muted-foreground"
182+
>
183+
{tag}
184+
</span>
185+
))}
186+
</div>
187+
)}
188+
189+
{/* 3. The Board */}
190+
{isLoading ? (
191+
<div className="px-4 py-8 text-sm text-muted-foreground">
192+
Loading board...
193+
</div>
194+
) : (
195+
<div className="flex h-[calc(100vh-14rem)] w-full flex-nowrap gap-4 overflow-x-auto overflow-y-hidden pb-4">
196+
{ISSUE.ISSUE_STATUS.map((status) => {
197+
const columnIssues = issues.filter((i) => i.status === status);
198+
199+
return (
200+
<div
201+
key={status}
202+
className="flex h-full w-[85vw] max-w-[320px] shrink-0 flex-col rounded-lg border bg-muted/30 p-3 lg:w-auto lg:min-w-[220px] lg:flex-1"
203+
onDragOver={(e) => e.preventDefault()}
204+
onDrop={(e) => handleDrop(e, status)}
205+
>
206+
<div className="mb-4 flex items-center justify-between px-1">
207+
<div className="flex items-center gap-2">
208+
<div
209+
className={`h-3 w-3 rounded-full ${STATUS_COLORS[status]}`}
210+
/>
211+
<h3 className="text-sm font-semibold">
212+
{formatStatus(status)}
213+
</h3>
214+
</div>
215+
<span className="rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
216+
{columnIssues.length}
217+
</span>
218+
</div>
219+
220+
<div className="flex flex-1 flex-col gap-3 overflow-y-auto pb-2 pr-1">
221+
{columnIssues.map((issue) => (
222+
<div
223+
key={issue.id}
224+
draggable={true}
225+
onDragStart={(e) => handleDragStart(e, issue.id)}
226+
className="group cursor-grab rounded-lg border bg-card text-card-foreground shadow-sm transition-colors hover:border-primary/50 active:cursor-grabbing"
227+
>
228+
<div className="p-3">
229+
<div className="flex items-start justify-between gap-2">
230+
<Link
231+
href={"/issues/" + issue.id}
232+
className="text-sm font-medium leading-tight hover:underline"
233+
>
234+
{issue.name}
235+
</Link>
236+
237+
<CreateEditDialog
238+
intent="edit"
239+
initialValues={{
240+
id: issue.id,
241+
status: issue.status,
242+
name: issue.name,
243+
description: issue.description,
244+
links: issue.links ?? [],
245+
date: issue.date ?? undefined,
246+
priority: issue.priority,
247+
team: issue.team,
248+
parent: issue.parent ?? undefined,
249+
isEvent: issue.event !== null,
250+
event: issue.event,
251+
}}
252+
onDelete={(values) => {
253+
if (!values.id || deleteIssueMutation.isPending)
254+
return;
255+
deleteIssueMutation.mutate({ id: values.id });
256+
}}
257+
>
258+
<button className="text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100">
259+
<Pencil className="h-3 w-3" />
260+
</button>
261+
</CreateEditDialog>
262+
</div>
263+
264+
<div className="mt-3 flex items-center gap-4 text-xs text-muted-foreground">
265+
{issue.date && (
266+
<div className="flex items-center gap-1">
267+
<Calendar className="h-3 w-3" />
268+
<span>{formatDate(issue.date)}</span>
269+
</div>
270+
)}
271+
<div className="flex items-center gap-1">
272+
<Users className="h-3 w-3" />
273+
<span className="max-w-[100px] truncate">
274+
{paneData?.roleNameById.get(issue.team) ??
275+
issue.team}
276+
</span>
277+
</div>
278+
</div>
279+
</div>
280+
</div>
281+
))}
282+
</div>
283+
</div>
284+
);
285+
})}
286+
287+
<div className="w-1 shrink-0 lg:hidden" aria-hidden="true" />
288+
</div>
289+
)}
290+
291+
{/* 4. Issue Fetcher Pane */}
292+
<IssueFetcherPane
293+
open={isFiltersOpen}
294+
onOpenChange={setIsFiltersOpen}
295+
onDataChange={setPaneData}
296+
/>
297+
</section>
298+
);
299+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { notFound, redirect } from "next/navigation";
2+
3+
import { auth } from "@forge/auth";
4+
5+
import { KanbanBoard } from "~/app/_components/issue-kanban/issues-kanban";
6+
import { SessionNavbar } from "~/app/_components/navigation/session-navbar";
7+
import { SIGN_IN_PATH } from "~/consts";
8+
import { api, HydrateClient } from "~/trpc/server";
9+
10+
export default async function KanbanPage() {
11+
const session = await auth();
12+
if (!session) redirect(SIGN_IN_PATH);
13+
14+
const hasAccess = await api.roles.hasPermission({
15+
and: [
16+
"READ_ISSUES",
17+
"EDIT_ISSUES",
18+
"EDIT_ISSUE_TEMPLATES",
19+
"READ_ISSUE_TEMPLATES",
20+
],
21+
});
22+
if (!hasAccess) notFound();
23+
24+
return (
25+
<HydrateClient>
26+
<SessionNavbar />
27+
<main className="px-4 pb-4 md:px-6 md:pb-6">
28+
<KanbanBoard />
29+
</main>
30+
</HydrateClient>
31+
);
32+
}

0 commit comments

Comments
 (0)