Spaces Admin CRUD Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add complete CRUD management for Spaces, Space Groups, and Tags to the admin panel, including backend search/filter/ordering support.
Architecture: Backend adds search/filter/ordering to ViewSets. Frontend follows the established 3-layer pattern (apiClient → useDataTable/useResourceMutations → Components) with shared types, form dialogs, and data tables.
Tech Stack: Django REST Framework, React 19, TanStack Router, TanStack Query, shadcn/ui (@repo/ui), Tailwind CSS, sonner (toast)
Task 1: Backend — Add Search/Filter/Ordering to ViewSets
Covers: [S3]
Files:
Modify:
apps/spaces/views/spaces.py[ ] Step 1: Add imports and configure SpaceViewSet
In apps/spaces/views/spaces.py, add at the top:
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilterUpdate SpaceViewSet:
class SpaceViewSet(viewsets.ModelViewSet):
serializer_class = SpaceSerializer
permission_classes = [
RequiresCommunity,
permissions.IsAuthenticated,
RequiresActiveMembership,
IsAdminOrModeratorForWrite,
]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['space_type', 'access_level', 'space_group', 'is_welcome_space']
search_fields = ['name', 'description']
ordering_fields = ['name', 'order', 'created_at']
def get_queryset(self):
return Space.objects.visible_to(self.request.membership)- [ ] Step 2: Configure SpaceGroupViewSet
class SpaceGroupViewSet(viewsets.ModelViewSet):
serializer_class = SpaceGroupSerializer
permission_classes = [
RequiresCommunity,
permissions.IsAuthenticated,
RequiresActiveMembership,
IsAdminOrModeratorForWrite,
]
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['name']
ordering_fields = ['name', 'order']
def get_queryset(self):
return SpaceGroup.objects.filter(community=self.request.community)- [ ] Step 3: Configure TagViewSet
class TagViewSet(viewsets.ModelViewSet):
serializer_class = TagSerializer
permission_classes = [
RequiresCommunity,
permissions.IsAuthenticated,
RequiresActiveMembership,
IsAdminOrModeratorForWrite,
]
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ['name']
ordering_fields = ['name']
def get_queryset(self):
return Tag.objects.filter(community=self.request.community)- [ ] Step 4: Verify imports exist
Check that django_filters is in requirements.txt or pyproject.toml. If not, add it.
- [ ] Step 5: Commit
git add apps/spaces/views/spaces.py
git commit -m "feat(admin-api): add search/filter/ordering to Spaces ViewSets"Task 2: Frontend — Shared Space Types
Covers: [S4]
Files:
Create:
frontend/apps/admin/src/types/space.ts[ ] Step 1: Create shared types
// frontend/apps/admin/src/types/space.ts
export interface Space {
id: number;
space_group: number | null;
name: string;
slug: string;
description: string;
space_type: 'basic' | 'chat' | 'event' | 'course' | 'members' | 'image';
access_level: 'public' | 'private' | 'secret';
access_groups: number[];
is_welcome_space: boolean;
order: number;
}
export interface SpaceGroup {
id: number;
name: string;
slug: string;
order: number;
}
export interface Tag {
id: number;
name: string;
slug: string;
}- [ ] Step 2: Verify TypeScript compiles
Run: cd frontend/apps/admin && bunx tsc --noEmit
- [ ] Step 3: Commit
git add frontend/apps/admin/src/types/space.ts
git commit -m "feat(admin): add shared Space, SpaceGroup, Tag types"Task 3: Frontend — Space List + Form + Delete Dialogs
Covers: [S5]
Files:
Create:
frontend/apps/admin/src/components/Spaces/SpaceList.tsxCreate:
frontend/apps/admin/src/components/Spaces/SpaceDetail.tsxCreate:
frontend/apps/admin/src/components/Spaces/Modals/SpaceFormDialog.tsxCreate:
frontend/apps/admin/src/components/Spaces/Modals/SpaceDeleteDialog.tsxCreate:
frontend/apps/admin/src/routes/spaces/index.tsxCreate:
frontend/apps/admin/src/routes/spaces/$spaceId.tsx[ ] Step 1: Create SpaceList component
// frontend/apps/admin/src/components/Spaces/SpaceList.tsx
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { Button, Input, Badge } from '@repo/ui';
import { Plus, Pencil, Trash2, Eye } from 'lucide-react';
import { useDataTable, useResourceMutations } from '@/hooks/useDataTable';
import { UserListSkeleton } from '@/components/skeletons';
import { SpaceFormDialog } from './Modals/SpaceFormDialog';
import { SpaceDeleteDialog } from './Modals/SpaceDeleteDialog';
import type { Space } from '@/types/space';
const SPACE_TYPE_LABELS: Record<string, string> = {
basic: 'Básico',
chat: 'Chat',
event: 'Evento',
course: 'Curso',
members: 'Membros',
image: 'Imagem',
};
const ACCESS_LEVEL_LABELS: Record<string, string> = {
public: 'Público',
private: 'Privado',
secret: 'Secreto',
};
interface SpaceListProps {
page: number;
onPageChange: (page: number) => void;
search: string;
onSearchChange: (search: string) => void;
spaceType: string;
onSpaceTypeChange: (val: string) => void;
accessLevel: string;
onAccessLevelChange: (val: string) => void;
ordering: string;
onOrderingChange: (ordering: string) => void;
}
export function SpaceList({
page, onPageChange,
search, onSearchChange,
spaceType, onSpaceTypeChange,
accessLevel, onAccessLevelChange,
ordering, onOrderingChange,
}: SpaceListProps) {
const [formOpen, setFormOpen] = useState(false);
const [editSpace, setEditSpace] = useState<Space | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Space | null>(null);
const {
items: spaces, total, totalPages, isLoading,
} = useDataTable<Space>({
endpoint: '/api/v1/spaces/',
queryKey: 'spaces',
page, onPageChange,
search, onSearchChange,
ordering, onOrderingChange,
extraParams: {
...(spaceType && { space_type: spaceType }),
...(accessLevel && { access_level: accessLevel }),
},
});
const { deleteItem } = useResourceMutations<Space>('/api/v1/spaces/', 'spaces');
if (isLoading) return <UserListSkeleton />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Espaços</h1>
<p className="text-sm text-muted-foreground">
Gerencie os espaços da comunidade.
</p>
</div>
<Button onClick={() => { setEditSpace(null); setFormOpen(true); }}>
<Plus className="mr-2 h-4 w-4" /> Novo Espaço
</Button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<Input
placeholder="Buscar espaços..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="max-w-sm"
/>
<select
value={spaceType}
onChange={(e) => onSpaceTypeChange(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Todos os tipos</option>
{Object.entries(SPACE_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={accessLevel}
onChange={(e) => onAccessLevelChange(e.target.value)}
className="rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Todos os níveis</option>
{Object.entries(ACCESS_LEVEL_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
{/* Table */}
<div className="rounded-md border">
<table className="w-full caption-bottom text-sm">
<thead className="[&_tr]:border-b">
<tr className="border-b transition-colors hover:bg-muted/50">
<th className="h-12 px-4 text-left font-medium">Nome</th>
<th className="h-12 px-4 text-left font-medium">Slug</th>
<th className="h-12 px-4 text-left font-medium">Tipo</th>
<th className="h-12 px-4 text-left font-medium">Acesso</th>
<th className="h-12 px-4 text-left font-medium">Ordem</th>
<th className="h-12 px-4 text-right font-medium">Ações</th>
</tr>
</thead>
<tbody>
{spaces.map((space) => (
<tr key={space.id} className="border-b transition-colors hover:bg-muted/50">
<td className="p-4 font-medium">{space.name}</td>
<td className="p-4 text-muted-foreground">{space.slug}</td>
<td className="p-4">
<Badge variant="outline">{SPACE_TYPE_LABELS[space.space_type]}</Badge>
</td>
<td className="p-4">
<Badge variant="secondary">{ACCESS_LEVEL_LABELS[space.access_level]}</Badge>
</td>
<td className="p-4">{space.order}</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-1">
<Link to="/spaces/$spaceId" params={{ spaceId: String(space.id) }}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => { setEditSpace(space); setFormOpen(true); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
onClick={() => setDeleteTarget(space)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{total} espaço(s) encontrado(s)
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
Anterior
</Button>
<span className="text-sm">
Página {page} de {totalPages}
</span>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
Próxima
</Button>
</div>
</div>
<SpaceFormDialog open={formOpen} onOpenChange={setFormOpen} space={editSpace} />
{deleteTarget && (
<SpaceDeleteDialog
space={deleteTarget}
open={!!deleteTarget}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
/>
)}
</div>
);
}- [ ] Step 2: Create SpaceFormDialog component
// frontend/apps/admin/src/components/Spaces/Modals/SpaceFormDialog.tsx
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Button, Input, Dialog, DialogContent, DialogHeader, DialogTitle } from '@repo/ui';
import { useResourceMutations, useDataTable } from '@/hooks/useDataTable';
import { apiFetch } from '@/api/apiClient';
import type { Space, SpaceGroup } from '@/types/space';
interface SpaceFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
space: Space | null;
}
function slugify(text: string): string {
return text
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
export function SpaceFormDialog({ open, onOpenChange, space }: SpaceFormDialogProps) {
const isEdit = !!space;
const { createItem, updateItem, isCreating, isUpdating } = useResourceMutations<Space>('/api/v1/spaces/', 'spaces');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [description, setDescription] = useState('');
const [spaceType, setSpaceType] = useState<Space['space_type']>('basic');
const [accessLevel, setAccessLevel] = useState<Space['access_level']>('public');
const [spaceGroupId, setSpaceGroupId] = useState<number | ''>('');
const [isWelcome, setIsWelcome] = useState(false);
const [order, setOrder] = useState(0);
const [error, setError] = useState('');
const { items: spaceGroups } = useDataTable<SpaceGroup>({
endpoint: '/api/v1/space-groups/',
queryKey: 'space-groups',
pageSize: 100,
});
useEffect(() => {
if (space) {
setName(space.name);
setSlug(space.slug);
setDescription(space.description);
setSpaceType(space.space_type);
setAccessLevel(space.access_level);
setSpaceGroupId(space.space_group ?? '');
setIsWelcome(space.is_welcome_space);
setOrder(space.order);
} else {
setName(''); setSlug(''); setDescription('');
setSpaceType('basic'); setAccessLevel('public');
setSpaceGroupId(''); setIsWelcome(false); setOrder(0);
}
setError('');
}, [space, open]);
useEffect(() => {
if (!isEdit && name) setSlug(slugify(name));
}, [name, isEdit]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const payload = {
name, slug, description, space_type: spaceType,
access_level: accessLevel,
space_group: spaceGroupId || null,
is_welcome_space: isWelcome,
order,
};
if (isEdit) {
await updateItem({ id: space.id, data: payload });
toast.success('Espaço atualizado');
} else {
await createItem(payload);
toast.success('Espaço criado');
}
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao salvar');
toast.error('Erro ao salvar espaço');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar Espaço' : 'Novo Espaço'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && <div className="text-sm text-destructive">{error}</div>}
<div className="space-y-2">
<label className="text-sm font-medium">Nome *</label>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Slug *</label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Descrição</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm min-h-[80px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tipo *</label>
<select value={spaceType} onChange={(e) => setSpaceType(e.target.value as Space['space_type'])}
className="w-full rounded-md border bg-background px-3 py-2 text-sm">
<option value="basic">Básico</option>
<option value="chat">Chat</option>
<option value="event">Evento</option>
<option value="course">Curso</option>
<option value="members">Membros</option>
<option value="image">Imagem</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Nível de Acesso *</label>
<select value={accessLevel} onChange={(e) => setAccessLevel(e.target.value as Space['access_level'])}
className="w-full rounded-md border bg-background px-3 py-2 text-sm">
<option value="public">Público</option>
<option value="private">Privado</option>
<option value="secret">Secreto</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Grupo de Espaço</label>
<select value={spaceGroupId} onChange={(e) => setSpaceGroupId(e.target.value ? Number(e.target.value) : '')}
className="w-full rounded-md border bg-background px-3 py-2 text-sm">
<option value="">Nenhum</option>
{spaceGroups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Ordem</label>
<Input type="number" value={order} onChange={(e) => setOrder(Number(e.target.value))} />
</div>
{spaceType === 'basic' && (
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="is_welcome" checked={isWelcome}
onChange={(e) => setIsWelcome(e.target.checked)} className="rounded" />
<label htmlFor="is_welcome" className="text-sm font-medium">Espaço de Boas-vindas</label>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button type="submit" disabled={isCreating || isUpdating}>
{isEdit ? 'Salvar' : 'Criar'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}- [ ] Step 3: Create SpaceDeleteDialog component
// frontend/apps/admin/src/components/Spaces/Modals/SpaceDeleteDialog.tsx
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useResourceMutations } from '@/hooks/useDataTable';
import type { Space } from '@/types/space';
interface SpaceDeleteDialogProps {
space: Space;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SpaceDeleteDialog({ space, open, onOpenChange }: SpaceDeleteDialogProps) {
const { deleteItem, isDeleting } = useResourceMutations<Space>('/api/v1/spaces/', 'spaces');
const handleDelete = async () => {
try {
await deleteItem(space.id);
toast.success('Espaço excluído');
onOpenChange(false);
} catch {
toast.error('Erro ao excluir espaço');
}
};
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title="Excluir Espaço"
description={`Tem certeza que deseja excluir "${space.name}"? Esta ação não pode ser desfeita.`}
onConfirm={handleDelete}
confirmText="Excluir"
loading={isDeleting}
/>
);
}- [ ] Step 4: Create SpaceList route
// frontend/apps/admin/src/routes/spaces/index.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { SpaceList } from '@/components/Spaces/SpaceList';
interface SpacesSearch {
page?: number;
search?: string;
space_type?: string;
access_level?: string;
ordering?: string;
}
export const Route = createFileRoute('/spaces/')({
validateSearch: (search: Record<string, unknown>): SpacesSearch => {
const pageNum = Number(search?.page);
return {
page: !isNaN(pageNum) && pageNum > 0 ? pageNum : 1,
search: typeof search?.search === 'string' ? search.search : '',
space_type: typeof search?.space_type === 'string' ? search.space_type : '',
access_level: typeof search?.access_level === 'string' ? search.access_level : '',
ordering: typeof search?.ordering === 'string' ? search.ordering : '-order',
};
},
component: SpacesPage,
});
function SpacesPage() {
const { page = 1, search = '', space_type = '', access_level = '', ordering = '-order' } = Route.useSearch();
const navigate = useNavigate({ from: '/spaces/' });
const update = (key: string, value: string | number | undefined) => {
navigate({ search: (prev) => ({ ...prev, [key]: value || undefined, page: 1 }), replace: true });
};
return (
<SpaceList
page={page} onPageChange={(p) => update('page', p)}
search={search} onSearchChange={(s) => update('search', s)}
spaceType={space_type} onSpaceTypeChange={(v) => update('space_type', v)}
accessLevel={access_level} onAccessLevelChange={(v) => update('access_level', v)}
ordering={ordering} onOrderingChange={(o) => update('ordering', o)}
/>
);
}- [ ] Step 5: Create SpaceDetail component + route
// frontend/apps/admin/src/components/Spaces/SpaceDetail.tsx
import { useState } from 'react';
import { Link, useNavigate } from '@tanstack/react-router';
import { Button, Badge, Card, CardContent, CardHeader, CardTitle } from '@repo/ui';
import { ArrowLeft, Pencil, Trash2 } from 'lucide-react';
import { useResourceItem } from '@/hooks/useDataTable';
import { UserDetailSkeleton } from '@/components/skeletons';
import { SpaceFormDialog } from './Modals/SpaceFormDialog';
import { SpaceDeleteDialog } from './Modals/SpaceDeleteDialog';
import type { Space } from '@/types/space';
const SPACE_TYPE_LABELS: Record<string, string> = {
basic: 'Básico', chat: 'Chat', event: 'Evento',
course: 'Curso', members: 'Membros', image: 'Imagem',
};
const ACCESS_LEVEL_LABELS: Record<string, string> = {
public: 'Público', private: 'Privado', secret: 'Secreto',
};
export function SpaceDetail({ spaceId }: { spaceId: string }) {
const navigate = useNavigate();
const { data: space, isLoading } = useResourceItem<Space>('/api/v1/spaces/', spaceId);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) return <UserDetailSkeleton />;
if (!space) return <div className="p-6 text-muted-foreground">Espaço não encontrado.</div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate({ to: '/spaces' })}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">{space.name}</h1>
<p className="text-sm text-muted-foreground">/{space.slug}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setEditOpen(true)}>
<Pencil className="mr-2 h-4 w-4" /> Editar
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" /> Excluir
</Button>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader><CardTitle>Informações</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between"><span className="text-muted-foreground">ID</span><span>{space.id}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Tipo</span>
<Badge variant="outline">{SPACE_TYPE_LABELS[space.space_type]}</Badge></div>
<div className="flex justify-between"><span className="text-muted-foreground">Acesso</span>
<Badge variant="secondary">{ACCESS_LEVEL_LABELS[space.access_level]}</Badge></div>
<div className="flex justify-between"><span className="text-muted-foreground">Ordem</span><span>{space.order}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Boas-vindas</span>
<span>{space.is_welcome_space ? 'Sim' : 'Não'}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Detalhes</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div>
<span className="text-muted-foreground text-sm">Descrição</span>
<p className="mt-1">{space.description || 'Sem descrição'}</p>
</div>
<div>
<span className="text-muted-foreground text-sm">Grupo</span>
<p className="mt-1">{space.space_group ?? 'Nenhum'}</p>
</div>
<div>
<span className="text-muted-foreground text-sm">Grupos de Acesso</span>
<p className="mt-1">{space.access_groups.length > 0 ? space.access_groups.join(', ') : 'Nenhum'}</p>
</div>
</CardContent>
</Card>
</div>
<SpaceFormDialog open={editOpen} onOpenChange={setEditOpen} space={space} />
<SpaceDeleteDialog space={space} open={deleteOpen} onOpenChange={setDeleteOpen} />
</div>
);
}// frontend/apps/admin/src/routes/spaces/$spaceId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { SpaceDetail } from '@/components/Spaces/SpaceDetail';
export const Route = createFileRoute('/spaces/$spaceId')({
component: SpaceDetailPage,
});
function SpaceDetailPage() {
const { spaceId } = Route.useParams();
return <SpaceDetail spaceId={spaceId} />;
}- [ ] Step 6: Verify TypeScript compiles
Run: cd frontend/apps/admin && bunx tsc --noEmit
- [ ] Step 7: Commit
git add frontend/apps/admin/src/components/Spaces/ frontend/apps/admin/src/routes/spaces/
git commit -m "feat(admin): add Space CRUD (list, detail, create/edit, delete)"Task 4: Frontend — SpaceGroup CRUD
Covers: [S6]
Files:
Create:
frontend/apps/admin/src/components/SpaceGroups/SpaceGroupList.tsxCreate:
frontend/apps/admin/src/components/SpaceGroups/Modals/SpaceGroupFormDialog.tsxCreate:
frontend/apps/admin/src/components/SpaceGroups/Modals/SpaceGroupDeleteDialog.tsxCreate:
frontend/apps/admin/src/routes/space-groups.tsx[ ] Step 1: Create SpaceGroupList component
Follow the same pattern as SpaceList but simpler — columns: Name, Slug, Order, Actions. No type/access filters.
- [ ] Step 2: Create SpaceGroupFormDialog
Fields: name (required), slug (auto-generated), order (number). POST/PATCH to /api/v1/space-groups/.
- [ ] Step 3: Create SpaceGroupDeleteDialog
Confirmation with group name. DELETE to /api/v1/space-groups/{id}/.
- [ ] Step 4: Create route
Route file at routes/space-groups.tsx with search params for page, search, ordering.
[ ] Step 5: Verify TypeScript compiles
[ ] Step 6: Commit
git add frontend/apps/admin/src/components/SpaceGroups/ frontend/apps/admin/src/routes/space-groups.tsx
git commit -m "feat(admin): add SpaceGroup CRUD (list, create/edit, delete)"Task 5: Frontend — Tag CRUD
Covers: [S7]
Files:
Create:
frontend/apps/admin/src/components/Tags/TagList.tsxCreate:
frontend/apps/admin/src/components/Tags/Modals/TagFormDialog.tsxCreate:
frontend/apps/admin/src/components/Tags/Modals/TagDeleteDialog.tsxCreate:
frontend/apps/admin/src/routes/tags.tsx[ ] Step 1: Create TagList component
Simplest CRUD — columns: Name, Slug, Actions. Search by name, sort by name.
- [ ] Step 2: Create TagFormDialog
Fields: name (required), slug (auto-generated). POST/PATCH to /api/v1/tags/.
- [ ] Step 3: Create TagDeleteDialog
Confirmation with tag name. DELETE to /api/v1/tags/{id}/.
- [ ] Step 4: Create route
Route file at routes/tags.tsx with search params for page, search, ordering.
[ ] Step 5: Verify TypeScript compiles
[ ] Step 6: Commit
git add frontend/apps/admin/src/components/Tags/ frontend/apps/admin/src/routes/tags.tsx
git commit -m "feat(admin): add Tag CRUD (list, create/edit, delete)"Task 6: Frontend — Update Sidebar + Route Registration
Covers: [S8, S9]
Files:
Modify:
frontend/apps/admin/src/components/layout/data/sidebar-data.tsModify:
frontend/apps/admin/src/routeTree.gen.ts(auto-regenerated)[ ] Step 1: Update sidebar navigation
In sidebar-data.ts, update the Conteúdo group:
{
title: 'Conteúdo',
items: [
{ title: 'Espaços', url: '/spaces/', icon: LayoutGrid },
{ title: 'Groups', url: '/space-groups', icon: FolderOpen },
{ title: 'Tags', url: '/tags', icon: Tag },
{ title: 'Eventos', url: '/events', icon: Calendar, disabled: true },
{ title: 'Cursos', url: '/courses', icon: BookOpen, disabled: true },
],
},Import LayoutGrid, FolderOpen, Tag from lucide-react.
- [ ] Step 2: Regenerate route tree
Run: cd frontend/apps/admin && bunx @tanstack/router-cli generate
This auto-generates routeTree.gen.ts with the new routes.
[ ] Step 3: Verify TypeScript compiles
[ ] Step 4: Commit
git add frontend/apps/admin/src/components/layout/data/sidebar-data.ts frontend/apps/admin/src/routeTree.gen.ts
git commit -m "feat(admin): update sidebar with Spaces, Groups, Tags navigation"Verification
After all tasks:
cd frontend/apps/admin && bunx tsc --noEmit— passes (no new errors)cd frontend/apps/admin && bun run dev— starts without errors- Navigate to
/spaces— list loads with real data from backend - Create/edit/delete spaces works with toast feedback
- Navigate to
/space-groups— list loads - Create/edit/delete space groups works
- Navigate to
/tags— list loads - Create/edit/delete tags works
- Sidebar shows all new items, disabled items still grayed out