Files
task-team/apps/tasks/app/tasks/page.tsx
Admin e250b2124f Fix display_name TS error + clean rebuild
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:42:40 +00:00

357 lines
13 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { getTasks, getGroups, createTask, updateTask, Task, Group } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
import TaskCard from "@/components/TaskCard";
import TaskModal from "@/components/TaskModal";
import { useSwipeable } from "react-swipeable";
const STATUS_VALUES = ["pending", "in_progress", "done", "cancelled"] as const;
type StatusValue = (typeof STATUS_VALUES)[number];
function statusColor(s: StatusValue | null): string {
switch (s) {
case "pending": return "#FBBF24";
case "in_progress": return "#60A5FA";
case "done": return "#34D399";
case "cancelled": return "#9CA3AF";
default: return "#7A7A9A";
}
}
export default function TasksPage() {
const { token, user } = useAuth();
const { t } = useTranslation();
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<StatusValue | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [showForm, setShowForm] = useState(false);
const [swipeDirection, setSwipeDirection] = useState<"left" | "right" | null>(null);
const [swipeOverlay, setSwipeOverlay] = useState<{ name: string; icon: string | null } | null>(null);
// Build group order: [null (all), group1.id, group2.id, ...]
const groupOrder = useMemo(() => {
return [null, ...groups.map((g) => g.id)];
}, [groups]);
const currentGroupIndex = useMemo(() => {
return groupOrder.indexOf(selectedGroup);
}, [groupOrder, selectedGroup]);
const selectedGroupObj = useMemo(
() => (selectedGroup ? groups.find((g) => g.id === selectedGroup) ?? null : null),
[selectedGroup, groups]
);
const loadData = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const params: Record<string, string> = {};
if (selectedGroup) params.group_id = selectedGroup;
if (selectedStatus) params.status = selectedStatus;
const [tasksRes, groupsRes] = await Promise.all([
getTasks(token, Object.keys(params).length > 0 ? params : undefined),
getGroups(token),
]);
setTasks(tasksRes.data || []);
setGroups(groupsRes.data || []);
} catch (err) {
console.error("Load error:", err);
} finally {
setLoading(false);
}
}, [token, selectedGroup, selectedStatus]);
useEffect(() => {
if (!token) {
router.replace("/login");
return;
}
loadData();
}, [token, router, loadData]);
// Navigate to next/previous group via swipe
const navigateGroup = useCallback(
(direction: "left" | "right") => {
if (groupOrder.length <= 1) return;
const newIndex =
direction === "left"
? (currentGroupIndex + 1) % groupOrder.length
: (currentGroupIndex - 1 + groupOrder.length) % groupOrder.length;
const newGroupId = groupOrder[newIndex];
if (newGroupId === null) {
setSwipeOverlay({ name: t("tasks.all"), icon: null });
} else {
const group = groups.find((g) => g.id === newGroupId);
setSwipeOverlay({ name: group?.name || "", icon: group?.icon || null });
}
setSwipeDirection(direction);
setSelectedGroup(newGroupId);
setTimeout(() => {
setSwipeOverlay(null);
setSwipeDirection(null);
}, 600);
},
[groupOrder, currentGroupIndex, groups, t]
);
// Swipe handlers for cycling groups
const swipeHandlers = useSwipeable({
onSwipedLeft: () => navigateGroup("left"),
onSwipedRight: () => navigateGroup("right"),
trackMouse: false,
trackTouch: true,
preventScrollOnSwipe: false,
delta: 50,
swipeDuration: 500,
});
async function handleCreateTask(data: Partial<Task>) {
if (!token) return;
await createTask(token, data);
setShowForm(false);
loadData();
}
async function handleCompleteTask(taskId: string) {
if (!token) return;
try {
await updateTask(token, taskId, { status: "done" });
loadData();
} catch (err) {
console.error("Complete error:", err);
}
}
if (!token) return null;
const userInitial = (user?.name || user?.email || "?").charAt(0).toUpperCase();
return (
<div className="pb-24 sm:pb-8">
{/* Single-row sticky filter header — group dropdown left, status pills right */}
<div style={{
display: "flex", alignItems: "center",
padding: "0 8px",
position: "sticky", top: 44, zIndex: 40,
height: 40, maxHeight: 40,
background: "var(--background, #fff)",
borderBottom: "1px solid var(--border, #e5e7eb)",
flexWrap: "nowrap", overflow: "hidden",
}}>
{/* Groups dropdown — compact, left side */}
<div style={{ position: "relative", flexShrink: 0 }}>
<button
onClick={() => { setGroupOpen(o => !o); }}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "4px 10px", borderRadius: 16, height: 32,
border: `1.5px solid ${selectedGroupObj?.color || "var(--border, #3A3A4A)"}`,
background: selectedGroupObj ? selectedGroupObj.color + "18" : "transparent",
color: selectedGroupObj?.color || "var(--foreground, #374151)",
fontSize: 12, fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap",
}}
>
{selectedGroupObj
? `${selectedGroupObj.icon ?? ""} ${(selectedGroupObj as any).name}`
: t("tasks.all")}
<span style={{ fontSize: 8, opacity: 0.5, marginLeft: 1 }}>{groupOpen ? "\u25b2" : "\u25bc"}</span>
</button>
{groupOpen && (
<>
<div onClick={() => setGroupOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 150 }} />
<div style={{
position: "absolute", top: "110%", left: 0, zIndex: 200,
background: "var(--popover, #fff)", border: "1px solid var(--border, #e5e7eb)",
borderRadius: 12, padding: "4px 0", minWidth: 180,
boxShadow: "0 8px 24px rgba(0,0,0,0.15)",
}}>
<button
onClick={() => { setSelectedGroup(null); setGroupOpen(false); }}
style={{
width: "100%", padding: "9px 14px",
background: !selectedGroup ? "rgba(29,78,216,0.08)" : "transparent",
border: "none", borderLeft: `3px solid ${!selectedGroup ? "#1D4ED8" : "transparent"}`,
color: !selectedGroup ? "#2563EB" : "var(--foreground, #374151)",
textAlign: "left", fontSize: 13, cursor: "pointer",
}}
>
{t("tasks.all")}
</button>
{groups.map(g => (
<button
key={g.id}
onClick={() => { setSelectedGroup(g.id); setGroupOpen(false); }}
style={{
width: "100%", padding: "9px 14px",
background: selectedGroup === g.id ? g.color + "18" : "transparent",
border: "none", borderLeft: `3px solid ${selectedGroup === g.id ? g.color : "transparent"}`,
color: selectedGroup === g.id ? g.color : "var(--foreground, #374151)",
textAlign: "left", fontSize: 13, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
}}
>
<span>{g.icon}</span>
{g.name}
</button>
))}
</div>
</>
)}
</div>
{/* Status pills — horizontal scroll, right side */}
<div style={{
display: "flex", alignItems: "center", gap: 4,
flex: 1, overflow: "hidden", marginLeft: 0,
justifyContent: "flex-end",
}}
className="scrollbar-hide"
>
<div style={{
display: "flex", alignItems: "center", gap: 3,
overflowX: "auto", flexShrink: 1,
}}
className="scrollbar-hide"
>
{/* "All" pill */}
<button
onClick={() => setSelectedStatus(null)}
style={{
padding: "3px 10px", borderRadius: 14, height: 28,
border: "none", cursor: "pointer", whiteSpace: "nowrap",
fontSize: 11, fontWeight: selectedStatus === null ? 700 : 500,
background: selectedStatus === null ? "rgba(29,78,216,0.12)" : "transparent",
color: selectedStatus === null ? "#2563EB" : "var(--muted, #6B7280)",
transition: "all 0.15s ease",
flexShrink: 0,
}}
>
{t("tasks.all")}
</button>
{STATUS_VALUES.map(s => (
<button
key={s}
onClick={() => setSelectedStatus(selectedStatus === s ? null : s)}
style={{
display: "flex", alignItems: "center", gap: 4,
padding: "3px 10px", borderRadius: 14, height: 28,
border: "none", cursor: "pointer", whiteSpace: "nowrap",
fontSize: 11, fontWeight: selectedStatus === s ? 700 : 500,
background: selectedStatus === s ? statusColor(s) + "20" : "transparent",
color: selectedStatus === s ? statusColor(s) : "var(--muted, #6B7280)",
transition: "all 0.15s ease",
flexShrink: 0,
}}
>
<span style={{
width: 6, height: 6, borderRadius: "50%",
background: statusColor(s), flexShrink: 0,
}} />
{t(`tasks.status.${s}`)}
</button>
))}
</div>
</div>
</div>
{/* Swipeable task list area */}
<div {...swipeHandlers} className="relative min-h-[200px] px-4 sm:px-0 pt-2">
{/* Swipe overlay */}
{swipeOverlay && (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
<div
className={`bg-black/70 dark:bg-white/20 backdrop-blur-md text-white px-6 py-3 rounded-2xl flex items-center gap-3 shadow-2xl ${
swipeDirection === "left" ? "animate-slideOverlayLeft" : "animate-slideOverlayRight"
}`}
>
{swipeOverlay.icon && (
<span className="text-2xl">{swipeOverlay.icon}</span>
)}
<span className="text-lg font-semibold">{swipeOverlay.name}</span>
</div>
</div>
)}
{/* Task list */}
<div
className={`transition-all duration-300 ease-out ${
swipeDirection === "left"
? "animate-slideContentLeft"
: swipeDirection === "right"
? "animate-slideContentRight"
: ""
}`}
>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-7 w-7 border-b-2 border-blue-600" />
</div>
) : tasks.length === 0 ? (
<div className="text-center py-12">
<div className="text-4xl mb-3 opacity-50">&#9744;</div>
<p className="text-muted text-base font-medium">{t("tasks.noTasks")}</p>
<p className="text-muted text-sm mt-1">
{t("tasks.createFirst")}
</p>
</div>
) : (
<div className="space-y-1">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onComplete={handleCompleteTask}
/>
))}
</div>
)}
</div>
</div>
{/* Floating action button */}
<button
onClick={() => setShowForm(true)}
className="fixed bottom-20 sm:bottom-6 right-4 sm:right-6 w-14 h-14 bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white rounded-full shadow-lg hover:shadow-xl active:shadow-md transition-all duration-200 flex items-center justify-center z-40 hover:scale-105 active:scale-95"
aria-label={t("tasks.add")}
>
<svg
className="w-7 h-7"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
{/* Add task modal */}
{showForm && (
<TaskModal
groups={groups}
onSubmit={handleCreateTask}
onClose={() => setShowForm(false)}
/>
)}
</div>
);
}