libra/docs/stories/story-5.2-post-management-d...

7.2 KiB

Story 5.2: Post Management Dashboard

Epic Reference

Epic 5: Posts/Blog System

User Story

As an admin, I want a dashboard to manage all blog posts, So that I can organize, publish, and maintain content efficiently.

Story Context

Existing System Integration

  • Integrates with: posts table
  • Technology: Livewire Volt with pagination
  • Follows pattern: Admin list pattern
  • Touch points: Post CRUD operations

Acceptance Criteria

List View

  • Display all posts with:
    • Title (in current admin language)
    • Status (draft/published)
    • Created date
    • Last updated date
  • Pagination (10/25/50 per page)
  • Filter by status (draft/published/all)
  • Search by title or body content
  • Sort by date (newest/oldest)

Quick Actions

  • Edit post
  • Delete (with confirmation)
  • Publish/unpublish toggle
  • Bulk delete option (optional)

Quality Requirements

  • Bilingual labels
  • Audit log for delete actions
  • Tests for list operations

Technical Notes

Volt Component

<?php

use App\Models\Post;
use Livewire\Volt\Component;
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;

    public string $search = '';
    public string $statusFilter = '';
    public string $sortBy = 'created_at';
    public string $sortDir = 'desc';
    public int $perPage = 10;

    public function updatedSearch()
    {
        $this->resetPage();
    }

    public function sort(string $column): void
    {
        if ($this->sortBy === $column) {
            $this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
        } else {
            $this->sortBy = $column;
            $this->sortDir = 'desc';
        }
    }

    public function togglePublish(int $id): void
    {
        $post = Post::findOrFail($id);
        $newStatus = $post->status === 'published' ? 'draft' : 'published';

        $post->update(['status' => $newStatus]);

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'status_change',
            'target_type' => 'post',
            'target_id' => $post->id,
            'old_values' => ['status' => $post->getOriginal('status')],
            'new_values' => ['status' => $newStatus],
            'ip_address' => request()->ip(),
        ]);

        session()->flash('success', __('messages.post_status_updated'));
    }

    public function delete(int $id): void
    {
        $post = Post::findOrFail($id);

        AdminLog::create([
            'admin_id' => auth()->id(),
            'action_type' => 'delete',
            'target_type' => 'post',
            'target_id' => $post->id,
            'old_values' => $post->toArray(),
            'ip_address' => request()->ip(),
        ]);

        $post->delete();

        session()->flash('success', __('messages.post_deleted'));
    }

    public function with(): array
    {
        $locale = app()->getLocale();

        return [
            'posts' => Post::query()
                ->when($this->search, fn($q) => $q->where(function($q) use ($locale) {
                    $q->where("title_{$locale}", 'like', "%{$this->search}%")
                      ->orWhere("body_{$locale}", 'like', "%{$this->search}%");
                }))
                ->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
                ->orderBy($this->sortBy, $this->sortDir)
                ->paginate($this->perPage),
        ];
    }
};

Template

<div>
    <div class="flex justify-between items-center mb-6">
        <flux:heading>{{ __('admin.posts') }}</flux:heading>
        <flux:button href="{{ route('admin.posts.create') }}">
            {{ __('admin.create_post') }}
        </flux:button>
    </div>

    <!-- Filters -->
    <div class="flex gap-4 mb-4">
        <flux:input
            wire:model.live.debounce="search"
            placeholder="{{ __('admin.search_posts') }}"
            class="w-64"
        />
        <flux:select wire:model.live="statusFilter">
            <option value="">{{ __('admin.all_statuses') }}</option>
            <option value="draft">{{ __('admin.draft') }}</option>
            <option value="published">{{ __('admin.published') }}</option>
        </flux:select>
    </div>

    <!-- Posts Table -->
    <table class="w-full">
        <thead>
            <tr>
                <th wire:click="sort('title_{{ app()->getLocale() }}')" class="cursor-pointer">
                    {{ __('admin.title') }}
                </th>
                <th>{{ __('admin.status') }}</th>
                <th wire:click="sort('created_at')" class="cursor-pointer">
                    {{ __('admin.created') }}
                </th>
                <th wire:click="sort('updated_at')" class="cursor-pointer">
                    {{ __('admin.updated') }}
                </th>
                <th>{{ __('admin.actions') }}</th>
            </tr>
        </thead>
        <tbody>
            @forelse($posts as $post)
                <tr>
                    <td>{{ $post->title }}</td>
                    <td>
                        <flux:badge :variant="$post->status === 'published' ? 'success' : 'secondary'">
                            {{ __('admin.' . $post->status) }}
                        </flux:badge>
                    </td>
                    <td>{{ $post->created_at->format('d/m/Y') }}</td>
                    <td>{{ $post->updated_at->diffForHumans() }}</td>
                    <td>
                        <div class="flex gap-2">
                            <flux:button size="sm" href="{{ route('admin.posts.edit', $post) }}">
                                {{ __('admin.edit') }}
                            </flux:button>
                            <flux:button
                                size="sm"
                                wire:click="togglePublish({{ $post->id }})"
                            >
                                {{ $post->status === 'published' ? __('admin.unpublish') : __('admin.publish') }}
                            </flux:button>
                            <flux:button
                                size="sm"
                                variant="danger"
                                wire:click="delete({{ $post->id }})"
                                wire:confirm="{{ __('admin.confirm_delete_post') }}"
                            >
                                {{ __('admin.delete') }}
                            </flux:button>
                        </div>
                    </td>
                </tr>
            @empty
                <tr>
                    <td colspan="5" class="text-center py-8 text-charcoal/70">
                        {{ __('admin.no_posts') }}
                    </td>
                </tr>
            @endforelse
        </tbody>
    </table>

    {{ $posts->links() }}
</div>

Definition of Done

  • List displays all posts
  • Filter by status works
  • Search by title/body works
  • Sort by date works
  • Quick publish/unpublish toggle works
  • Delete with confirmation works
  • Pagination works
  • Audit log for actions
  • Tests pass
  • Code formatted with Pint

Dependencies

  • Story 5.1: Post creation

Estimation

Complexity: Medium Estimated Effort: 3-4 hours