Skip to content

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:

python
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter

Update SpaceViewSet:

python
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
python
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
python
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
bash
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

typescript
// 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
bash
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.tsx

  • Create: frontend/apps/admin/src/components/Spaces/SpaceDetail.tsx

  • Create: frontend/apps/admin/src/components/Spaces/Modals/SpaceFormDialog.tsx

  • Create: frontend/apps/admin/src/components/Spaces/Modals/SpaceDeleteDialog.tsx

  • Create: frontend/apps/admin/src/routes/spaces/index.tsx

  • Create: frontend/apps/admin/src/routes/spaces/$spaceId.tsx

  • [ ] Step 1: Create SpaceList component

tsx
// 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
tsx
// 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
tsx
// 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
tsx
// 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
tsx
// 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>
  );
}
tsx
// 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
bash
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.tsx

  • Create: frontend/apps/admin/src/components/SpaceGroups/Modals/SpaceGroupFormDialog.tsx

  • Create: frontend/apps/admin/src/components/SpaceGroups/Modals/SpaceGroupDeleteDialog.tsx

  • Create: 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

bash
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.tsx

  • Create: frontend/apps/admin/src/components/Tags/Modals/TagFormDialog.tsx

  • Create: frontend/apps/admin/src/components/Tags/Modals/TagDeleteDialog.tsx

  • Create: 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

bash
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.ts

  • Modify: frontend/apps/admin/src/routeTree.gen.ts (auto-regenerated)

  • [ ] Step 1: Update sidebar navigation

In sidebar-data.ts, update the Conteúdo group:

typescript
{
  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

bash
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:

  1. cd frontend/apps/admin && bunx tsc --noEmit — passes (no new errors)
  2. cd frontend/apps/admin && bun run dev — starts without errors
  3. Navigate to /spaces — list loads with real data from backend
  4. Create/edit/delete spaces works with toast feedback
  5. Navigate to /space-groups — list loads
  6. Create/edit/delete space groups works
  7. Navigate to /tags — list loads
  8. Create/edit/delete tags works
  9. Sidebar shows all new items, disabled items still grayed out

Strum — Documentação.