refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events) - Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests) - Phase 2: Remove all `any` types from production code with proper typed replacements - Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility - Phase 4: Verify .DS_Store housekeeping (already clean) 0 TypeScript errors, 583/583 tests passing.
This commit is contained in:
11
admin-web/app/programs/CLAUDE.md
Normal file
11
admin-web/app/programs/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
11
admin-web/app/programs/[id]/CLAUDE.md
Normal file
11
admin-web/app/programs/[id]/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
11
admin-web/app/programs/[id]/edit/CLAUDE.md
Normal file
11
admin-web/app/programs/[id]/edit/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
81
admin-web/app/programs/[id]/edit/page.tsx
Normal file
81
admin-web/app/programs/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ProgramForm from "@/components/program-form"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface EditProgramPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort tabatas by position
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditProgramPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
return {
|
||||
title: "Program Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Edit ${program.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditProgramPage({ params }: EditProgramPageProps) {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href={`/programs/${program.id}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Program
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Edit Program</h1>
|
||||
<p className="text-neutral-400">
|
||||
Update the details for "{program.title}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<ProgramForm initialData={program} mode="edit" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
admin-web/app/programs/[id]/page.tsx
Normal file
292
admin-web/app/programs/[id]/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft, Edit, Trash2, Clock, Flame, Dumbbell, Zap, Timer, Target } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { supabase } from "@/lib/supabase"
|
||||
|
||||
interface ProgramDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
const BODY_ZONE_COLORS: Record<string, string> = {
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
"Beginner": "bg-emerald-500/20 text-emerald-500",
|
||||
"Intermediate": "bg-yellow-500/20 text-yellow-500",
|
||||
"Advanced": "bg-red-500/20 text-red-500",
|
||||
}
|
||||
|
||||
async function getProgram(id: string) {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (*)
|
||||
`)
|
||||
.eq("id", id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort tabatas by position
|
||||
if (data.program_tabatas) {
|
||||
data.program_tabatas.sort((a: any, b: any) => a.position - b.position)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProgramDetailPageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
return {
|
||||
title: "Program Not Found | TabataFit Admin",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${program.title} | TabataFit Admin`,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
|
||||
const resolvedParams = await params
|
||||
const program = await getProgram(resolvedParams.id)
|
||||
|
||||
if (!program) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const tabatas = program.program_tabatas || []
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{program.accent_color && (
|
||||
<div
|
||||
className="h-4 w-4 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: program.accent_color }}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold text-white">{program.title}</h1>
|
||||
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
|
||||
{program.is_free ? "Free" : "Premium"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Badge className={BODY_ZONE_COLORS[program.body_zone] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{program.body_zone}
|
||||
</Badge>
|
||||
<span className="text-neutral-600">|</span>
|
||||
<Badge className={LEVEL_COLORS[program.level] || "bg-neutral-500/20 text-neutral-500"}>
|
||||
{program.level}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<p className="text-neutral-400 mt-3 max-w-2xl">{program.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild className="border-neutral-700">
|
||||
<Link href={`/programs/${program.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">Duration</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.estimated_duration} min</p>
|
||||
<p className="text-xs text-neutral-500">estimated total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Flame className="h-4 w-4" />
|
||||
<span className="text-sm">Calories</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.estimated_calories}</p>
|
||||
<p className="text-xs text-neutral-500">estimated burn</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Dumbbell className="h-4 w-4" />
|
||||
<span className="text-sm">Tabatas</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{tabatas.length}</p>
|
||||
<p className="text-xs text-neutral-500">exercise pairs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm">Sort Order</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{program.sort_order}</p>
|
||||
<p className="text-xs text-neutral-500">display position</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabatas */}
|
||||
<div className="space-y-6">
|
||||
{tabatas.map((tabata: any) => (
|
||||
<Card key={tabata.id} className="bg-neutral-900 border-neutral-800">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-500/20 text-orange-500 font-bold text-sm">
|
||||
{tabata.position}
|
||||
</div>
|
||||
<CardTitle className="text-white">Tabata {tabata.position}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-neutral-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Timer className="h-3.5 w-3.5" />
|
||||
{tabata.rounds} rounds
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{tabata.work_time}s work
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{tabata.rest_time}s rest
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Exercise 1 */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-orange-500/20 text-orange-500">
|
||||
Exercise 1
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{tabata.exercise_1_name}</p>
|
||||
{tabata.exercise_1_name_en && (
|
||||
<p className="text-sm text-neutral-500">{tabata.exercise_1_name_en}</p>
|
||||
)}
|
||||
</div>
|
||||
{tabata.exercise_1_tip && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_tip}</p>
|
||||
{tabata.exercise_1_tip_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_tip_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_1_modification && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_modification}</p>
|
||||
{tabata.exercise_1_modification_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_modification_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_1_progression && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_1_progression}</p>
|
||||
{tabata.exercise_1_progression_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_1_progression_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exercise 2 */}
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-950 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="bg-blue-500/20 text-blue-500">
|
||||
Exercise 2
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{tabata.exercise_2_name}</p>
|
||||
{tabata.exercise_2_name_en && (
|
||||
<p className="text-sm text-neutral-500">{tabata.exercise_2_name_en}</p>
|
||||
)}
|
||||
</div>
|
||||
{tabata.exercise_2_tip && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Tip</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_tip}</p>
|
||||
{tabata.exercise_2_tip_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_tip_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_2_modification && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Modification</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_modification}</p>
|
||||
{tabata.exercise_2_modification_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_modification_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tabata.exercise_2_progression && (
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-0.5">Progression</p>
|
||||
<p className="text-sm text-neutral-300">{tabata.exercise_2_progression}</p>
|
||||
{tabata.exercise_2_progression_en && (
|
||||
<p className="text-xs text-neutral-500">{tabata.exercise_2_progression_en}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
admin-web/app/programs/new/CLAUDE.md
Normal file
11
admin-web/app/programs/new/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6319 | 9:38 PM | 🔵 | Discovered admin UI program management pages created | ~267 |
|
||||
</claude-mem-context>
|
||||
34
admin-web/app/programs/new/page.tsx
Normal file
34
admin-web/app/programs/new/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ProgramForm from "@/components/program-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "New Program | TabataFit Admin",
|
||||
description: "Create a new workout program",
|
||||
}
|
||||
|
||||
export default function NewProgramPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" asChild className="mb-4 text-neutral-400 hover:text-white">
|
||||
<Link href="/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Create New Program</h1>
|
||||
<p className="text-neutral-400">
|
||||
Fill in the details below to create a new workout program with 3 tabatas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900 p-6">
|
||||
<ProgramForm mode="create" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
318
admin-web/app/programs/page.tsx
Normal file
318
admin-web/app/programs/page.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Edit, Loader2, Eye } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Database } from "@/lib/supabase";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type WorkoutProgram = Database["public"]["Tables"]["workout_programs"]["Row"];
|
||||
|
||||
const BODY_ZONE_OPTIONS = [
|
||||
{ value: "all", label: "All Zones" },
|
||||
{ value: "upper-body", label: "Upper Body" },
|
||||
{ value: "lower-body", label: "Lower Body" },
|
||||
{ value: "full-body", label: "Full Body" },
|
||||
]
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: "all", label: "All Levels" },
|
||||
{ value: "Beginner", label: "Beginner" },
|
||||
{ value: "Intermediate", label: "Intermediate" },
|
||||
{ value: "Advanced", label: "Advanced" },
|
||||
]
|
||||
|
||||
export default function ProgramsPage() {
|
||||
const router = useRouter();
|
||||
const [programs, setPrograms] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [programToDelete, setProgramToDelete] = useState<WorkoutProgram | null>(null);
|
||||
const [filterZone, setFilterZone] = useState("all");
|
||||
const [filterLevel, setFilterLevel] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrograms();
|
||||
}, []);
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
try {
|
||||
const { data, error } = await (supabase.from("workout_programs") as any)
|
||||
.select(`
|
||||
*,
|
||||
program_tabatas (id)
|
||||
`)
|
||||
.order("sort_order", { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setPrograms(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch programs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = (program: WorkoutProgram) => {
|
||||
setProgramToDelete(program);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!programToDelete) return;
|
||||
|
||||
setDeletingId(programToDelete.id);
|
||||
try {
|
||||
// Delete tabatas first (foreign key constraint)
|
||||
const { error: tabataError } = await (supabase.from("program_tabatas") as any)
|
||||
.delete()
|
||||
.eq("program_id", programToDelete.id);
|
||||
if (tabataError) throw tabataError;
|
||||
|
||||
const { error } = await (supabase.from("workout_programs") as any)
|
||||
.delete()
|
||||
.eq("id", programToDelete.id);
|
||||
if (error) throw error;
|
||||
|
||||
setPrograms(programs.filter((p) => p.id !== programToDelete.id));
|
||||
toast.success("Program deleted", {
|
||||
description: "The program has been removed successfully."
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete program:", error);
|
||||
toast.error("Failed to delete program");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteDialogOpen(false);
|
||||
setProgramToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getBodyZoneColor = (zone: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"upper-body": "bg-green-500/20 text-green-500",
|
||||
"lower-body": "bg-purple-500/20 text-purple-500",
|
||||
"full-body": "bg-orange-500/20 text-orange-500",
|
||||
};
|
||||
return colors[zone] || "bg-neutral-500/20 text-neutral-500";
|
||||
};
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
"Beginner": "bg-emerald-500/20 text-emerald-500",
|
||||
"Intermediate": "bg-yellow-500/20 text-yellow-500",
|
||||
"Advanced": "bg-red-500/20 text-red-500",
|
||||
};
|
||||
return colors[level] || "bg-neutral-500/20 text-neutral-500";
|
||||
};
|
||||
|
||||
const filteredPrograms = programs.filter((p) => {
|
||||
if (filterZone !== "all" && p.body_zone !== filterZone) return false;
|
||||
if (filterLevel !== "all" && p.level !== filterLevel) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Programs</h1>
|
||||
<p className="text-neutral-400">Manage your workout programs</p>
|
||||
</div>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600" asChild>
|
||||
<Link href="/programs/new">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Program
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={filterZone}
|
||||
onValueChange={setFilterZone}
|
||||
options={BODY_ZONE_OPTIONS}
|
||||
placeholder="Filter by zone"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<Select
|
||||
value={filterLevel}
|
||||
onValueChange={setFilterLevel}
|
||||
options={LEVEL_OPTIONS}
|
||||
placeholder="Filter by level"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-neutral-900 border-neutral-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
) : filteredPrograms.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500">
|
||||
{programs.length === 0
|
||||
? "No programs yet. Create your first program to get started."
|
||||
: "No programs match your filters."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-neutral-800">
|
||||
<TableHead className="text-neutral-400">Title</TableHead>
|
||||
<TableHead className="text-neutral-400">Body Zone</TableHead>
|
||||
<TableHead className="text-neutral-400">Level</TableHead>
|
||||
<TableHead className="text-neutral-400">Access</TableHead>
|
||||
<TableHead className="text-neutral-400">Duration</TableHead>
|
||||
<TableHead className="text-neutral-400">Calories</TableHead>
|
||||
<TableHead className="text-neutral-400">Tabatas</TableHead>
|
||||
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPrograms.map((program) => (
|
||||
<TableRow
|
||||
key={program.id}
|
||||
className="border-neutral-800 cursor-pointer hover:bg-neutral-800/50 transition-colors"
|
||||
onClick={() => router.push(`/programs/${program.id}`)}
|
||||
>
|
||||
<TableCell className="text-white font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{program.accent_color && (
|
||||
<div
|
||||
className="h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: program.accent_color }}
|
||||
/>
|
||||
)}
|
||||
{program.title}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getBodyZoneColor(program.body_zone)}>
|
||||
{program.body_zone}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getLevelColor(program.level)}>
|
||||
{program.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={program.is_free ? "bg-emerald-500/20 text-emerald-500" : "bg-amber-500/20 text-amber-500"}>
|
||||
{program.is_free ? "Free" : "Premium"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-neutral-300">{program.estimated_duration} min</TableCell>
|
||||
<TableCell className="text-neutral-300">{program.estimated_calories}</TableCell>
|
||||
<TableCell className="text-neutral-300">
|
||||
{program.program_tabatas?.length || 0}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/programs/${program.id}`}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-white"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/programs/${program.id}/edit`}>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-neutral-400 hover:text-red-500"
|
||||
onClick={() => openDeleteDialog(program)}
|
||||
disabled={deletingId === program.id}
|
||||
>
|
||||
{deletingId === program.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-neutral-900 border-neutral-800 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Program</DialogTitle>
|
||||
<DialogDescription className="text-neutral-400">
|
||||
Are you sure you want to delete "{programToDelete?.title}"? This will also delete all associated tabatas. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!!deletingId}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
{deletingId ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user