Compare commits

..
7 Commits
Author SHA1 Message Date
Twyco be89a4900d feat(KAN-22): rebuild password confirmation flows
CI/CD / test_and_check_app (push) Successful in 4m50s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
2026-09-10 12:28:55 +02:00
Twyco a43aff0477 feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (push) Failing after 1m34s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-10 11:02:15 +02:00
Twyco 7edd8daf49 feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (push) Successful in 4m48s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5s
2026-09-10 09:26:12 +02:00
Twyco 718f6b991d feat(KAN-19): add responsive permission-aware navigation
CI/CD / test_and_check_app (push) Successful in 4m31s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 23:09:25 +02:00
Twyco 1b2349e977 feat(KAN-18): redesign foundational UI components
CI/CD / test_and_check_app (push) Successful in 4m25s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 15:31:04 +02:00
Twyco 95db152b91 feat(KAN-17): add Emerald design system and resilient themes
CI/CD / test_and_check_app (push) Successful in 4m13s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 14:29:16 +02:00
Twyco 5194ed0a17 feat(KAN-16): add English localization foundation
CI/CD / test_and_check_app (push) Successful in 4m9s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 3m45s
2026-09-09 14:02:20 +02:00
89 changed files with 2680 additions and 5295 deletions
@@ -47,7 +47,6 @@ class BuildProjectBacklogPayload
'tickets' => TicketResource::collection(
Ticket::query()
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -29,7 +29,6 @@ class BuildProjectBoardPayload
'ticketStatuses',
'tickets' => fn ($query) => $query
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
'acceptanceCriteria',
'assignee',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
@@ -1,24 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
+35
View File
@@ -0,0 +1,35 @@
# UI Rework 1: Englische Lokalisierungsgrundlage
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: Keine.
## Ziel und Grenzen
Englische Lokalisierungsgrundlage als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/app.ts](../../resources/js/app.ts); [resources/js/ssr.ts](../../resources/js/ssr.ts); [resources/js/types](../../resources/js/types). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Eine zentrale Übersetzungsstruktur für Vue einführen.
2. Englischen Katalog, Interpolation, Pluralformen und Formatierung festlegen.
3. Serverseitige Validierungstexte einheitlich Englisch halten.
## Akzeptanzkriterien
- [ ] Neue UI-Texte verwenden Schlüssel.
- [ ] Fehlende Schlüssel fallen im Test auf.
- [ ] Spätere Sprachen benötigen keine Änderungen an Komponenten.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+39
View File
@@ -0,0 +1,39 @@
# UI Rework 10: Persönliches Dashboard mit vorhandenen Daten
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 8](epic-1-issue-8.md), [Issue 11](epic-1-issue-11.md)
## Ziel und Grenzen
Persönliches Dashboard mit vorhandenen Daten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/Dashboard.vue](../../resources/js/pages/Dashboard.vue); [app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php](../../app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php); [routes/web.php](../../routes/web.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Persönliches Dashboard — 30b69ed84e5c4bdf8cd3fe26d7a9e803. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Dashboard aus zugewiesenen Tickets und sichtbaren Projekten aufbauen.
2. Vorhandene Action über einen Inertia-Controller und Resources anbinden.
3. Aus dem Mockup nicht belegbare Kennzahlen und Aktivitätsfeeds weglassen.
## Akzeptanzkriterien
- [ ] Keine Platzhalterdaten.
- [ ] Keine fremden Tickets.
- [ ] Leere und gefüllte Dashboards funktionieren.
- [ ] Neue Datenweitergabe ist durch Featuretests abgesichert.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/DashboardTest.php](../../tests/Feature/DashboardTest.php), [tests/Feature/Api/AssignedTicketsApiTest.php](../../tests/Feature/Api/AssignedTicketsApiTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+36
View File
@@ -0,0 +1,36 @@
# UI Rework 11: Ticketkarten und wiederverwendbare Ticketlisten
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 3](epic-1-issue-3.md)
## Ziel und Grenzen
Ticketkarten und wiederverwendbare Ticketlisten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/components](../../resources/js/components); [resources/js/types/models/ticket-model.ts](../../resources/js/types/models/ticket-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Neue Karten und Listenzeilen mit Ticketkey, Titel, Typ, Priorität, Zuweisung und Blockerzustand entwickeln.
2. Darstellung mit und ohne optionale Daten definieren.
3. Gemeinsame Props und Ereignisse für Board, Backlog und Dashboard festlegen. Ticketlinks über Wayfinder aufbauen und Fokusführung sowie Touch-Ziele prüfen; die Komponenten dürfen keine eigenen Datenabfragen auslösen.
## Akzeptanzkriterien
- [ ] Lange Titel bleiben bedienbar.
- [ ] Prioritäten und Status stammen aus Projektdaten.
- [ ] Links, Fokus und Touch-Ziele funktionieren.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 12: Boardansicht für Desktop und Mobilgeräte
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 8](epic-1-issue-8.md), [Issue 11](epic-1-issue-11.md)
## Ziel und Grenzen
Boardansicht für Desktop und Mobilgeräte als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Board.vue](../../resources/js/pages/projects/Board.vue); [app/Actions/Projects/BuildProjectBoardPayload.php](../../app/Actions/Projects/BuildProjectBoardPayload.php); [app/Http/Resources/BoardColumnResource.php](../../app/Http/Resources/BoardColumnResource.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Horizontales Board mit konfigurierten Spalten neu bauen.
2. Mobil eine Spalte mit Auswahl anbieten.
3. Statusunterteilung, Zähler, Leerzustände und vorhandene Statusauswahl darstellen.
## Akzeptanzkriterien
- [ ] Beliebige Spaltenanzahl funktioniert.
- [ ] Mehrere Status pro Spalte bleiben sichtbar.
- [ ] Board und Backlog behalten ihre bestehende fachliche Trennung.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+40
View File
@@ -0,0 +1,40 @@
# UI Rework 13: Drag-and-drop für bestehende Statuswechsel
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 12](epic-1-issue-12.md)
## Ziel und Grenzen
Drag-and-drop für bestehende Statuswechsel als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Board.vue](../../resources/js/pages/projects/Board.vue); [app/Http/Requests/MoveProjectTicketRequest.php](../../app/Http/Requests/MoveProjectTicketRequest.php); [app/Actions/Tickets/MoveProjectTicket.php](../../app/Actions/Tickets/MoveProjectTicket.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Board View — 1a8363ad911a494f92cc4468955ce2f2. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Ziehen per Maus und Touch ergänzen.
2. Zulässige Ziele aus Workflowdaten ableiten.
3. Bei mehreren Zielstatus Auswahl zeigen.
4. Vorhandenen Statusendpunkt über Wayfinder verwenden und Fehler zurückrollen.
## Akzeptanzkriterien
- [ ] Unzulässige Ziele sind gesperrt.
- [ ] Alternative Tastatur-/Menübedienung bleibt möglich.
- [ ] Kein doppeltes Worklog.
- [ ] Keine persistierte freie Kartenreihenfolge.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 14: Backlog und Ticketbaum
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 11](epic-1-issue-11.md)
## Ziel und Grenzen
Backlog und Ticketbaum als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Backlog.vue](../../resources/js/pages/projects/Backlog.vue); [resources/js/components/TicketTreeList.vue](../../resources/js/components/TicketTreeList.vue); [app/Actions/Projects/BuildProjectBacklogPayload.php](../../app/Actions/Projects/BuildProjectBacklogPayload.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Backlog mit vorhandenen Filtern und Hierarchie neu darstellen.
2. Eltern-Kind-Beziehungen, einklappbare Ebenen und vorhandene Aktionen integrieren.
3. Mobile Listenansicht erstellen.
## Akzeptanzkriterien
- [ ] Verschachtelte Tickets und leere Treffer funktionieren.
- [ ] Berechtigungen und Statusübergänge bleiben erhalten.
- [ ] Filter verändern keine fachlichen Daten.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBacklogTest.php](../../tests/Feature/Projects/ProjectBacklogTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 15: Neue Ticketdetailansicht und Seitenstruktur
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md), [Issue 11](epic-1-issue-11.md)
## Ziel und Grenzen
Neue Ticketdetailansicht und Seitenstruktur als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Actions/Projects/BuildProjectTicketDetailPayload.php](../../app/Actions/Projects/BuildProjectTicketDetailPayload.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Detailansicht in klar abgegrenzte Komponenten aufteilen.
2. Desktop-Inhalt und Metadatenbereich sowie mobile Stapelansicht neu aufbauen.
3. Stabile Einhängepunkte für Bearbeitung, Checkliste, Beziehungen, Historie und Git definieren.
## Akzeptanzkriterien
- [ ] Alle bestehenden Informationen bleiben zugänglich.
- [ ] Keine bestehende Funktion wird beim Umbau entfernt.
- [ ] Direkte Ticket-URLs funktionieren.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 16: Ticketanlage und Bearbeitungsformulare
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 15](epic-1-issue-15.md)
## Ziel und Grenzen
Ticketanlage und Bearbeitungsformulare als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/TicketCreate.vue](../../resources/js/pages/projects/TicketCreate.vue); [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Requests/StoreProjectTicketRequest.php](../../app/Http/Requests/StoreProjectTicketRequest.php); [app/Http/Requests/UpdateProjectTicketRequest.php](../../app/Http/Requests/UpdateProjectTicketRequest.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Formulare für vorhandene Ticketattribute neu erstellen.
2. Gemeinsame Feldkomponenten für Anlage und Bearbeitung verwenden.
3. Projektkonfigurierte Felder, Autor-/Story-Point-Schalter und Validierung erhalten.
## Akzeptanzkriterien
- [ ] Anlage und Bearbeitung speichern dieselben Attribute wie bisher.
- [ ] Fehler und ungespeicherte Änderungen sind abgedeckt.
- [ ] Gesperrte Aktionen bleiben serverseitig abgesichert.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 17: Akzeptanzkriterien-Checkliste
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 16](epic-1-issue-16.md)
## Ziel und Grenzen
Akzeptanzkriterien-Checkliste als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/lib/acceptance-criteria.ts](../../resources/js/lib/acceptance-criteria.ts); [resources/js/types/models/acceptance-criteria-model.ts](../../resources/js/types/models/acceptance-criteria-model.ts); [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Ticket Detail View — 934e3eb46764456195f0eb627e67a363. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Bestehende hierarchische Checkliste als neue Komponente aufbauen.
2. Hinzufügen, Bearbeiten, Verschachteln und Abhaken entsprechend dem Bestand erhalten.
3. In Anlage und Detail integrieren.
## Akzeptanzkriterien
- [ ] Hierarchie und Reihenfolge bleiben beim Speichern erhalten.
- [ ] Bestehende Kriterien gehen nicht verloren.
- [ ] Touch und Tastatur funktionieren.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 18: Ticketbeziehungen und Hierarchie
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 15](epic-1-issue-15.md), [Issue 16](epic-1-issue-16.md)
## Ziel und Grenzen
Ticketbeziehungen und Hierarchie als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Controllers/ProjectTicketLinkController.php](../../app/Http/Controllers/ProjectTicketLinkController.php); [resources/js/components/TicketTreeList.vue](../../resources/js/components/TicketTreeList.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Eltern-/Kindanzeige und Ticketverknüpfungen neu gestalten.
2. Bestehende Linktypen und Richtung verwenden.
3. Erlaubte Hinzufüge-/Entfernaktionen integrieren.
## Akzeptanzkriterien
- [ ] Bestehende Hierarchievalidierung bleibt wirksam.
- [ ] Linkrichtung wird korrekt angezeigt.
- [ ] Kein neuer Beziehungstyp wird eingeführt.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketLinkControllerTest.php](../../tests/Feature/Projects/ProjectTicketLinkControllerTest.php), [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 19: Blocker und Ticketänderungshistorie
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 15](epic-1-issue-15.md), [Issue 16](epic-1-issue-16.md)
## Ziel und Grenzen
Blocker und Ticketänderungshistorie als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [resources/js/types/models/ticket-worklog-entry-model.ts](../../resources/js/types/models/ticket-worklog-entry-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Blockieren/Entblockieren und Kommentarerfassung neu darstellen.
2. Vorhandenes Worklog als lesbare Historie integrieren.
3. Mobile Darstellung und lokale Datumsformatierung nutzen.
## Akzeptanzkriterien
- [ ] Blockieren erfordert weiterhin Kommentar.
- [ ] Historie zeigt echte Ereignisse und Akteure.
- [ ] Keine neue Kommentar- oder Aktivitätsfunktion.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketControllerTest.php](../../tests/Feature/Projects/ProjectTicketControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+36
View File
@@ -0,0 +1,36 @@
# UI Rework 2: Emerald-Designsystem und Hell/Dunkel/System
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 1](epic-1-issue-1.md)
## Ziel und Grenzen
Emerald-Designsystem und Hell/Dunkel/System als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/css/app.css](../../resources/css/app.css); [resources/js/composables/useAppearance.ts](../../resources/js/composables/useAppearance.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Farben, Typografie, Abstände, Radien und Fokuszustände aus Stitch als semantische Tokens definieren.
2. Inter lokal ausliefern.
3. Hell/Dunkel/System einschließlich initialem Laden umsetzen.
## Akzeptanzkriterien
- [ ] Beide Themes sind lesbar.
- [ ] Systemwechsel aktualisieren die Ansicht.
- [ ] Reduzierte Bewegung wird berücksichtigt.
- [ ] Keine Starterkit-Optik.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 20: Git-Bereich und Branch-Erstellung am Ticket
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 5](epic-1-issue-5.md), [Issue 15](epic-1-issue-15.md)
## Ziel und Grenzen
Git-Bereich und Branch-Erstellung am Ticket als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Ticket.vue](../../resources/js/pages/projects/Ticket.vue); [app/Http/Controllers/ProjectTicketBranchController.php](../../app/Http/Controllers/ProjectTicketBranchController.php); [resources/js/types/models/ticket-repository-branch-model.ts](../../resources/js/types/models/ticket-repository-branch-model.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Branch-/Pull-Request-Anzeigen sowie bestehenden Branch-Erstellablauf in eigene neue Komponenten überführen.
2. Repositoryauswahl, Namensvorschlag und vorhandene Prüfungen erhalten.
3. Laufende Anfragen, fehlenden Git-Zugriff und Providerfehler als eigene Zustände abbilden. Den Weg zu vorhandenen Verbindungseinstellungen anbieten, Formulareingaben bei Fehlern erhalten und externe Git-Links entsprechend kennzeichnen.
## Akzeptanzkriterien
- [ ] Fehlende Git-Verbindung und Providerfehler sind verständlich.
- [ ] Branch-Erstellung funktioniert.
- [ ] Fremde Git-Links öffnen extern.
- [ ] Bestehende Zuordnungsregeln bleiben erhalten.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketBranchControllerTest.php](../../tests/Feature/Projects/ProjectTicketBranchControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 21: Profil und Erscheinungsbild
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
## Ziel und Grenzen
Profil und Erscheinungsbild als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/settings/Profile.vue](../../resources/js/pages/settings/Profile.vue); [resources/js/pages/settings/Appearance.vue](../../resources/js/pages/settings/Appearance.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Profilfelder, Avatar-Upload/Entfernen und Erscheinungsbild neu gestalten.
2. Gemeinsame Einstellungsnavigation aufbauen.
3. Datenschutz durch bestehende Rechte und Endpunkte erhalten.
## Akzeptanzkriterien
- [ ] Avatar und Profiländerungen funktionieren.
- [ ] Hell/Dunkel/System bleibt nach Neustart erhalten.
- [ ] Uploadfehler und mobile Dateiauswahl sind berücksichtigt.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Settings/ProfileUpdateTest.php](../../tests/Feature/Settings/ProfileUpdateTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 22: Sicherheit und Anwendungsschlüssel
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 6](epic-1-issue-6.md), [Issue 7](epic-1-issue-7.md), [Issue 21](epic-1-issue-21.md)
## Ziel und Grenzen
Sicherheit und Anwendungsschlüssel als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/settings/Security.vue](../../resources/js/pages/settings/Security.vue); [resources/js/pages/settings/Applications.vue](../../resources/js/pages/settings/Applications.vue); [resources/js/components/TwoFactorSetupModal.vue](../../resources/js/components/TwoFactorSetupModal.vue); [resources/js/components/TwoFactorRecoveryCodes.vue](../../resources/js/components/TwoFactorRecoveryCodes.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Passwortverwaltung, 2FA-Einrichtung, Recovery-Codes und vorhandene Anwendungsschlüssel neu darstellen.
2. Sensible Bestätigungsabläufe erhalten.
3. Zustände vor und nach 2FA-Bestätigung sowie beim Anzeigen und Erneuern von Recovery-Codes anhand der bestehenden Endpunkte abbilden. Schlüsselanzeige und Kopierfunktion an die vorhandene Freigabelogik binden; fehlgeschlagene Bestätigungen direkt am Formular erklären.
## Akzeptanzkriterien
- [ ] 2FA-Aktivieren/Deaktivieren und Recovery-Codes funktionieren.
- [ ] Schlüssel werden weiterhin nur nach bestehender Logik angezeigt.
- [ ] Abbruch und Fehler sind sicher.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Settings/SecurityTest.php](../../tests/Feature/Settings/SecurityTest.php), [tests/Feature/Settings/UserApplicationTest.php](../../tests/Feature/Settings/UserApplicationTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 23: Persönliche Git-Verbindungen
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 21](epic-1-issue-21.md)
## Ziel und Grenzen
Persönliche Git-Verbindungen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/settings/GitConnections.vue](../../resources/js/pages/settings/GitConnections.vue); [app/Http/Controllers/Settings/GitConnectionController.php](../../app/Http/Controllers/Settings/GitConnectionController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Vorhandene Git-Verbindungen und Formulare neu gestalten.
2. Provider- und Serverangaben klar darstellen.
3. Validierungs- und Providerfehler in das neue Rückmeldesystem integrieren.
## Akzeptanzkriterien
- [ ] Erstellen, Bearbeiten und Entfernen bleiben möglich.
- [ ] Zugangsdaten werden nicht zusätzlich offengelegt.
- [ ] Leere Listen und fehlende Berechtigungen funktionieren.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Settings/GitConnectionTest.php](../../tests/Feature/Settings/GitConnectionTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 24: Allgemeine Projekteinstellungen und Vererbung
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 9](epic-1-issue-9.md), [Issue 5](epic-1-issue-5.md)
## Ziel und Grenzen
Allgemeine Projekteinstellungen und Vererbung als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/General.vue](../../resources/js/pages/projects/settings/General.vue); [resources/js/components/projects/TemplateInheritancePanel.vue](../../resources/js/components/projects/TemplateInheritancePanel.vue); [app/Http/Controllers/ProjectController.php](../../app/Http/Controllers/ProjectController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Einstellungsnavigation und allgemeine Projektseite neu aufbauen.
2. Vererbung und lokale Overrides nachvollziehbar anzeigen.
3. Bestehende Vorlagenwechsel-/Reset- und Löschbestätigungen erhalten.
## Akzeptanzkriterien
- [ ] Geerbte und lokale Werte sind unterscheidbar.
- [ ] Destruktive Aktionen erfordern bestehende Bestätigung.
- [ ] Keine neue Vorlagenlogik.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTemplateOverrideTest.php](../../tests/Feature/Projects/ProjectTemplateOverrideTest.php), [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 25: Workflow-Einstellungen für Spalten und Status
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 24](epic-1-issue-24.md)
## Ziel und Grenzen
Workflow-Einstellungen für Spalten und Status als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/Board.vue](../../resources/js/pages/projects/settings/Board.vue); [app/Http/Controllers/ProjectBoardSettingsController.php](../../app/Http/Controllers/ProjectBoardSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Spalten, Statuszuordnung, Namen und vorhandene Sortierung neu bearbeiten lassen.
2. Backlogstatus und Löschbedingungen aus dem Bestand übernehmen.
3. Übergangsmatrix als separate Komponente vorbereiten.
## Akzeptanzkriterien
- [ ] Vorhandene CRUD-Aktionen funktionieren.
- [ ] Gültige Positionen bleiben erhalten.
- [ ] Genutzte Status und Spalten werden nicht unzulässig entfernt.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardSettingsControllerTest.php](../../tests/Feature/Projects/ProjectBoardSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 26: Bearbeitung erlaubter Statusübergänge
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 25](epic-1-issue-25.md)
## Ziel und Grenzen
Bearbeitung erlaubter Statusübergänge als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/Board.vue](../../resources/js/pages/projects/settings/Board.vue); [app/Http/Controllers/ProjectBoardSettingsController.php](../../app/Http/Controllers/ProjectBoardSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Vorhandene Übergangsregeln als übersichtliche, mobil bedienbare Oberfläche neu erstellen.
2. Nur existierende Workflowfähigkeiten abbilden.
3. Gespeicherten Zustand und Fehler klar anzeigen.
## Akzeptanzkriterien
- [ ] Erlaubte und verbotene Übergänge werden korrekt gespeichert.
- [ ] Board-Dropziele entsprechen der Konfiguration.
- [ ] Keine neue Workflow-Engine.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectBoardSettingsControllerTest.php](../../tests/Feature/Projects/ProjectBoardSettingsControllerTest.php), [tests/Feature/Projects/ProjectBoardControllerTest.php](../../tests/Feature/Projects/ProjectBoardControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 27: Tickettypen, Prioritäten und Anzeigeoptionen
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 24](epic-1-issue-24.md)
## Ziel und Grenzen
Tickettypen, Prioritäten und Anzeigeoptionen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/Metadata.vue](../../resources/js/pages/projects/settings/Metadata.vue); [app/Http/Controllers/ProjectTicketMetadataSettingsController.php](../../app/Http/Controllers/ProjectTicketMetadataSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Typen, Prioritäten und bestehende Autor-/Story-Point-Optionen neu darstellen.
2. Hinzufügen, Umbenennen, Reihenfolge und bestehende Ersetzungsregeln beim Löschen erhalten.
3. Geschützte und bearbeitbare Einträge anhand vorhandener Berechtigungen unterscheiden. Vor dem Löschen die benötigte Ersatzzuordnung erklären und Validierungsfehler am betroffenen Eintrag anzeigen, ohne projektseitige Regeln zu verändern.
## Akzeptanzkriterien
- [ ] Geschützte Typen bleiben geschützt.
- [ ] Löschersatz und Reihenfolge funktionieren.
- [ ] Inhalte bleiben projektkonfigurierbar.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php](../../tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 28: Benutzerdefinierte Felder und Linktypen
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 27](epic-1-issue-27.md)
## Ziel und Grenzen
Benutzerdefinierte Felder und Linktypen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/Metadata.vue](../../resources/js/pages/projects/settings/Metadata.vue); [app/Http/Controllers/ProjectTicketMetadataSettingsController.php](../../app/Http/Controllers/ProjectTicketMetadataSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Bestehende Felddefinitionen und Linktypen in separate neue Einstellungsabschnitte aufteilen.
2. Vorhandene Feldtypen, Richtung und Gegenbezeichnungen erhalten.
3. Anlage, Bearbeitung und vorhandene Löschabläufe über die bestehenden Endpunkte anbinden. Ungültige Definitionen mit konkreten Feldfehlern zurückmelden und bestehende Schutzregeln für bereits verwendete Felder und Linktypen in der Oberfläche nachvollziehbar machen.
## Akzeptanzkriterien
- [ ] Keine neuen Feldtypen.
- [ ] Vorhandene Feldwerte bleiben erhalten.
- [ ] Linktypen und Gegenrichtung werden korrekt angezeigt und bearbeitet.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php](../../tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 29: Repository-Einstellungen des Projekts
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 23](epic-1-issue-23.md), [Issue 24](epic-1-issue-24.md)
## Ziel und Grenzen
Repository-Einstellungen des Projekts als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/Repositories.vue](../../resources/js/pages/projects/settings/Repositories.vue); [app/Http/Controllers/ProjectRepositorySettingsController.php](../../app/Http/Controllers/ProjectRepositorySettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Projekt-Repository-Verbindungen mit vorhandener Discovery, Auswahl und Bearbeitung neu aufbauen.
2. Persönliche Verbindungen und Projektzuordnung klar unterscheiden.
3. Ladezustände, leere Suchergebnisse, fehlende persönliche Verbindung und Providerfehler getrennt darstellen. Auf die vorhandenen persönlichen Git-Einstellungen verlinken und nach erfolgreicher Zuordnung die Projektliste aus der Serverantwort aktualisieren.
## Akzeptanzkriterien
- [ ] Vorhandene Provider werden unterstützt.
- [ ] Fehlende Verbindung und Providerfehler sind abgedeckt.
- [ ] Tickets nutzen weiterhin dieselben Repositories.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php](../../tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+35
View File
@@ -0,0 +1,35 @@
# UI Rework 3: Neue grundlegende UI-Komponenten
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 1](epic-1-issue-1.md), [Issue 2](epic-1-issue-2.md)
## Ziel und Grenzen
Neue grundlegende UI-Komponenten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/components/ui](../../resources/js/components/ui). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Buttons, Eingaben, Auswahlfelder, Checkboxen, Badges und Avatare neu gestalten.
2. Zugängliches Verhalten vorhandener Headless-Primitiven wiederverwenden.
3. Öffentliche Props für Folgetickets stabil festlegen.
## Akzeptanzkriterien
- [ ] Tastatur, Fokus, disabled, invalid und loading funktionieren.
- [ ] Sichtbare Texte sind übersetzbar.
- [ ] Komponenten funktionieren in beiden Themes.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 30: Projekt-Benachrichtigungseinstellungen
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 24](epic-1-issue-24.md)
## Ziel und Grenzen
Projekt-Benachrichtigungseinstellungen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/settings/Notifications.vue](../../resources/js/pages/projects/settings/Notifications.vue); [app/Http/Controllers/ProjectNotificationSettingsController.php](../../app/Http/Controllers/ProjectNotificationSettingsController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Bestehende Aktionen, Trigger, Filter und Nachrichtenvorlagen neu gestalten.
2. Vorhandene Delivery-Informationen und Validierung nutzen.
3. Sensible Webhookfelder sorgfältig darstellen.
## Akzeptanzkriterien
- [ ] Bestehende Benachrichtigungen lassen sich unverändert konfigurieren.
- [ ] Keine neuen Trigger oder Push-Dienste.
- [ ] Fehlerfälle sind getestet.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php](../../tests/Feature/Projects/ProjectNotificationSettingsControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 31: Verwaltung wiederverwendbarer Projektvorlagen
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 9](epic-1-issue-9.md), [Issue 24](epic-1-issue-24.md)
## Ziel und Grenzen
Verwaltung wiederverwendbarer Projektvorlagen als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/project-templates/Index.vue](../../resources/js/pages/project-templates/Index.vue); [resources/js/pages/project-templates/Create.vue](../../resources/js/pages/project-templates/Create.vue); [resources/js/pages/project-templates/Edit.vue](../../resources/js/pages/project-templates/Edit.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Vorlagenübersicht, Erstellung und Bearbeitung neu aufbauen.
2. Auswirkungen auf vererbende Projekte und Bestätigungen darstellen.
3. Interne Basisvorlagen weiterhin ausblenden.
## Akzeptanzkriterien
- [ ] Vorhandene Erstell-/Bearbeitungsflüsse funktionieren.
- [ ] Unveränderliche Slugs bleiben gesperrt.
- [ ] Keine Änderungen an Vererbungsregeln.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectTemplateControllerTest.php](../../tests/Feature/Projects/ProjectTemplateControllerTest.php), [tests/Feature/Projects/ProjectTemplateOverrideTest.php](../../tests/Feature/Projects/ProjectTemplateOverrideTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+36
View File
@@ -0,0 +1,36 @@
# UI Rework 32: Teams und Mitgliedschaft
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
## Ziel und Grenzen
Teams und Mitgliedschaft als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/teams/Index.vue](../../resources/js/pages/teams/Index.vue); [app/Http/Controllers/TeamController.php](../../app/Http/Controllers/TeamController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Teamübersicht, Teamverwaltung und Mitgliedsauswahl im neuen Design umsetzen.
2. Alle bestehenden Rechte und Aktionen übernehmen.
3. Mobile Bedienung sicherstellen.
## Akzeptanzkriterien
- [ ] Team- und Mitgliedsaktionen entsprechen dem Bestand.
- [ ] Fehlende Rechte und Validierungsfehler sind abgedeckt.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Teams/TeamControllerTest.php](../../tests/Feature/Teams/TeamControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 33: Projektzugriff und Rechtezuweisung
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 8](epic-1-issue-8.md), [Issue 32](epic-1-issue-32.md)
## Ziel und Grenzen
Projektzugriff und Rechtezuweisung als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Access.vue](../../resources/js/pages/projects/Access.vue); [app/Http/Controllers/ProjectController.php](../../app/Http/Controllers/ProjectController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Direkte Benutzerrechte und Teamrechte neu darstellen.
2. Effektive Rechte und Bearbeitungsgrenzen nachvollziehbar machen.
3. Bestehende Zuweisungsendpunkte weiterverwenden.
## Akzeptanzkriterien
- [ ] Keine neue Rollenlogik.
- [ ] Rechte gelten weiterhin additiv nach Bestand.
- [ ] Unberechtigte Zuweisungen werden serverseitig abgewiesen.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php), [tests/Feature/Permissions/PermissionServiceTest.php](../../tests/Feature/Permissions/PermissionServiceTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 34: Benutzeradministration
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
## Ziel und Grenzen
Benutzeradministration als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/admin/users/Index.vue](../../resources/js/pages/admin/users/Index.vue); [resources/js/pages/admin/users/Create.vue](../../resources/js/pages/admin/users/Create.vue); [resources/js/pages/admin/users/Edit.vue](../../resources/js/pages/admin/users/Edit.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Übersicht, Benutzeranlage und Bearbeitung vollständig neu gestalten.
2. Vorhandene Felder, Adminoptionen und erzwungenen Passwortwechsel erhalten.
3. Verfügbare Aktionen aus den bestehenden Berechtigungen ableiten, sensible Änderungen bestätigen lassen und Validierungsfehler direkt den Eingaben zuordnen. Nach Speichern oder Abbruch zur passenden Übersicht zurückführen, ohne neue Rollen oder Verwaltungsaktionen einzuführen.
## Akzeptanzkriterien
- [ ] Bestehende Adminaktionen funktionieren.
- [ ] Nicht-Admins erhalten keinen Zugriff.
- [ ] Formfehler und sensible Aktionen sind abgedeckt.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Admin/UserManagementControllerTest.php](../../tests/Feature/Admin/UserManagementControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+36
View File
@@ -0,0 +1,36 @@
# UI Rework 35: Öffentliche Einstiegsseite
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 2](epic-1-issue-2.md), [Issue 3](epic-1-issue-3.md), [Issue 6](epic-1-issue-6.md)
## Ziel und Grenzen
Öffentliche Einstiegsseite als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/Welcome.vue](../../resources/js/pages/Welcome.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Relevanter Stitch-Screen: Landing Page - Open Source & Self-Hosted — 41a744db2b1f4e01a9c597835b58759e. Zugriff über Stitch-MCP, Projekt 9754281177402648015.
## Implementierungsweg
1. Neue kompakte öffentliche Einstiegsseite mit KanbanGit-Branding und Anmeldung entwickeln.
2. Stitch-Landingpage ausschließlich als Stilreferenz nutzen.
3. Nur vorhandene Funktionen beschreiben.
## Akzeptanzkriterien
- [ ] Keine erfundenen Produktfunktionen, Tarife oder Statistiken.
- [ ] Responsiv und lokalisierbar.
- [ ] Einstieg in Anmeldung funktioniert.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 36: Tauri-Grundgerüst und macOS-Entwicklungsbuild
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: Keine.
## Ziel und Grenzen
Tauri-Grundgerüst und macOS-Entwicklungsbuild als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native/` (in diesem Ticket neu anzulegen); [.gitignore](../../.gitignore). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Remote-Inhalte und native Berechtigungen](https://v2.tauri.app/security/capabilities/).
## Implementierungsweg
1. Isoliertes Tauri-2-Projekt im gemeinsamen Repository anlegen.
2. Minimale lokale Startansicht und Server-WebView erstellen.
3. Entwicklungsbuild auf macOS mit vorhandenem KanbanGit prüfen.
## Akzeptanzkriterien
- [ ] Laravel bleibt am bisherigen Ort.
- [ ] macOS-App startet und zeigt die Serveroberfläche.
- [ ] Keine allgemeine native API für Remote-Inhalte.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 37: Serverauswahl, Sitzungen und Verbindungsfehler
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 36](epic-1-issue-36.md), [Issue 6](epic-1-issue-6.md), [Issue 7](epic-1-issue-7.md)
## Ziel und Grenzen
Serverauswahl, Sitzungen und Verbindungsfehler als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native` (entsteht ab Issue 36); [resources/js/pages/auth](../../resources/js/pages/auth). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Remote-Inhalte und native Berechtigungen](https://v2.tauri.app/security/capabilities/).
## Implementierungsweg
1. Eine aktive, frei wählbare HTTPS-Installation speichern.
2. Serverwechsel und lokale Fehleransicht umsetzen.
3. Sitzungen, Logout, 2FA, Start ohne Verbindung und Formularfehler prüfen.
## Akzeptanzkriterien
- [ ] Kein Zertifikats-Bypass.
- [ ] Sitzungserhalt folgt Laravel.
- [ ] Serverwechsel übernimmt keine fremden Zugangsdaten.
- [ ] Bestehende Eingaben werden bei Verbindungsfehler nicht durch einen leeren Bildschirm ersetzt.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 38: Android-App und Plattformanpassung
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 37](epic-1-issue-37.md)
## Ziel und Grenzen
Android-App und Plattformanpassung als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native (Android-Plattformprojekt)` (entsteht ab Issue 36). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/).
## Implementierungsweg
1. Android-Ziel konfigurieren und Entwicklungsbuild erstellen.
2. Zurück-Taste, Systemleisten, Safe Areas und Bildschirmtastatur anpassen.
3. Bestehende Server-/Anmeldeabläufe prüfen.
## Akzeptanzkriterien
- [ ] App startet auf Emulator und geeignetem Gerät.
- [ ] Login, 2FA, Navigation und Wiederaufnahme funktionieren.
- [ ] Keine eigenständige zweite Fachoberfläche.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 39: iOS/iPadOS-App für lokale Xcode-Builds
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 37](epic-1-issue-37.md)
## Ziel und Grenzen
iOS/iPadOS-App für lokale Xcode-Builds als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native (Apple-Plattformprojekt)` (entsteht ab Issue 36). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/).
## Implementierungsweg
1. IOS/iPadOS-Ziel für lokalen Xcode-Build konfigurieren.
2. WKWebView-Sitzungen, Safe Areas, Tastatur und Größenwechsel prüfen.
3. Benötigte lokale Einrichtung im Ticket dokumentieren.
## Akzeptanzkriterien
- [ ] Simulatorbuild funktioniert.
- [ ] Gerätebuild nutzt vom Nutzer bereitgestellte Signierung.
- [ ] Kein TestFlight- oder Store-Upload.
- [ ] IPad bleibt bedienbar.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+35
View File
@@ -0,0 +1,35 @@
# UI Rework 4: Responsive Anwendungsnavigation
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 3](epic-1-issue-3.md)
## Ziel und Grenzen
Responsive Anwendungsnavigation als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/layouts](../../resources/js/layouts); [resources/js/components/AppSidebar.vue](../../resources/js/components/AppSidebar.vue); [resources/js/types/navigation.ts](../../resources/js/types/navigation.ts). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Neue Sidebar, obere Werkzeugleiste, Projektnavigation und mobile Navigation aufbauen.
2. Nur vorhandene und erlaubte Bereiche verlinken.
3. Aktuelle Auswahl und kompakte Bildschirmgrößen berücksichtigen.
## Akzeptanzkriterien
- [ ] Alle bestehenden Bereiche bleiben erreichbar.
- [ ] Rollen sehen nur erlaubte Links.
- [ ] Browser-Zurück und mobile Navigation funktionieren.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 40: App-Links, externe Links und Dateiauswahl
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 20](epic-1-issue-20.md), [Issue 21](epic-1-issue-21.md), [Issue 38](epic-1-issue-38.md), [Issue 39](epic-1-issue-39.md)
## Ziel und Grenzen
App-Links, externe Links und Dateiauswahl als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native` (entsteht ab Issue 36); [resources/js/components](../../resources/js/components). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Remote-Inhalte und native Berechtigungen](https://v2.tauri.app/security/capabilities/).
## Implementierungsweg
1. Interne App-Links zur ausgewählten Installation und externe Browserlinks umsetzen.
2. Avatar-Dateiauswahl, Zwischenablage und Browser-Zurück plattformübergreifend absichern.
3. Native Schnittstellen eng begrenzen.
## Akzeptanzkriterien
- [ ] Fremde Links wechseln nicht ungefragt den Server.
- [ ] Externe Git-Links funktionieren.
- [ ] Uploads und Kopieren funktionieren auf allen App-Plattformen.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 41: Signierter macOS-Release-Download
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 40](epic-1-issue-40.md)
## Ziel und Grenzen
Signierter macOS-Release-Download als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native` (entsteht ab Issue 36); [.github/workflows](../../.github/workflows). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [macOS-Signierung](https://v2.tauri.app/distribute/sign/macos/).
## Implementierungsweg
1. Reproduzierbaren macOS-Releasebuild mit Signierung und Notarisierung vorbereiten.
2. Zertifikate nur als externe Secrets erwarten.
3. Installationsartefakt für Git-Releases erzeugen.
## Akzeptanzkriterien
- [ ] Builds verwenden keine eingecheckten Schlüssel.
- [ ] Signiertes Artefakt lässt sich mit bereitgestellten Voraussetzungen installieren.
- [ ] Fehlende Secrets werden klar gemeldet.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+38
View File
@@ -0,0 +1,38 @@
# UI Rework 42: Signiertes Android-APK für Git-Releases
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 40](epic-1-issue-40.md)
## Ziel und Grenzen
Signiertes Android-APK für Git-Releases als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native` (entsteht ab Issue 36); [.github/workflows](../../.github/workflows). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/), [Android-Releasevorbereitung](https://developer.android.com/studio/publish/preparing).
## Implementierungsweg
1. Reproduzierbaren signierten APK-Releasebuild konfigurieren.
2. Dauerhafte Signing-Identität über Secrets beziehen.
3. Versionierung und Upgrade vorhandener Installationen berücksichtigen.
## Akzeptanzkriterien
- [ ] APK ist direkt installierbar.
- [ ] Updates behalten Daten/Sitzungen entsprechend Plattformverhalten.
- [ ] Schlüssel fehlen im Repository.
- [ ] Kein Play-Store-Upload.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+39
View File
@@ -0,0 +1,39 @@
# UI Rework 43: Getrennte Prüf- und Build-Pipelines
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 39](epic-1-issue-39.md), [Issue 41](epic-1-issue-41.md), [Issue 42](epic-1-issue-42.md)
## Ziel und Grenzen
Getrennte Prüf- und Build-Pipelines als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [Dockerfile](../../Dockerfile); [.github/workflows/ci_cd.yml](../../.github/workflows/ci_cd.yml); [.github/tests/test_and_check_app/action.yml](../../.github/tests/test_and_check_app/action.yml). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
Technische Referenz: [Tauri 2](https://v2.tauri.app/).
## Implementierungsweg
1. Laravel/Web-Prüfungen von Tauri-Builds trennen.
2. Passende Linux-/macOS-Runner festlegen.
3. Änderungen nach Pfaden zuordnen, gemeinsame Änderungen vollständig prüfen.
4. Bestehendes Serverrelease erhalten.
## Akzeptanzkriterien
- [ ] Serverimage enthält keine native Toolchain.
- [ ] Erforderliche Prüfungen werden nicht übersprungen.
- [ ] macOS/Android-Artefakte sind getrennt.
- [ ] iOS bleibt Selbstbau.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 44: Web-Abnahme und Entfernung des alten Frontends
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 1](epic-1-issue-1.md), [Issue 2](epic-1-issue-2.md), [Issue 3](epic-1-issue-3.md), [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md), [Issue 6](epic-1-issue-6.md), [Issue 7](epic-1-issue-7.md), [Issue 8](epic-1-issue-8.md), [Issue 9](epic-1-issue-9.md), [Issue 10](epic-1-issue-10.md), [Issue 11](epic-1-issue-11.md), [Issue 12](epic-1-issue-12.md), [Issue 13](epic-1-issue-13.md), [Issue 14](epic-1-issue-14.md), [Issue 15](epic-1-issue-15.md), [Issue 16](epic-1-issue-16.md), [Issue 17](epic-1-issue-17.md), [Issue 18](epic-1-issue-18.md), [Issue 19](epic-1-issue-19.md), [Issue 20](epic-1-issue-20.md), [Issue 21](epic-1-issue-21.md), [Issue 22](epic-1-issue-22.md), [Issue 23](epic-1-issue-23.md), [Issue 24](epic-1-issue-24.md), [Issue 25](epic-1-issue-25.md), [Issue 26](epic-1-issue-26.md), [Issue 27](epic-1-issue-27.md), [Issue 28](epic-1-issue-28.md), [Issue 29](epic-1-issue-29.md), [Issue 30](epic-1-issue-30.md), [Issue 31](epic-1-issue-31.md), [Issue 32](epic-1-issue-32.md), [Issue 33](epic-1-issue-33.md), [Issue 34](epic-1-issue-34.md), [Issue 35](epic-1-issue-35.md)
## Ziel und Grenzen
Web-Abnahme und Entfernung des alten Frontends als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js](../../resources/js); [resources/css](../../resources/css); [tests/Feature](../../tests/Feature). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Alle vorhandenen Seiten und Aktionen gegen die Bestandsliste prüfen.
2. Desktop/Tablet/Mobil sowie Hell/Dunkel/System visuell und funktional abnehmen.
3. Nur nicht mehr referenzierte alte UI-Dateien entfernen.
4. Größere neue Befunde in eigene Folgeaufgaben abgrenzen.
## Akzeptanzkriterien
- [ ] Keine Starterkit-Seite oder unübersetzte neue UI-Texte.
- [ ] Keine verlorenen Bestandsfunktionen.
- [ ] Keine bestehenden Tests entfernt.
- [ ] Relevante Tests, Build, Lint und abschließender Typecheck bestehen.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+36
View File
@@ -0,0 +1,36 @@
# UI Rework 45: Plattformübergreifende App-Abnahme
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 40](epic-1-issue-40.md), [Issue 41](epic-1-issue-41.md), [Issue 42](epic-1-issue-42.md), [Issue 43](epic-1-issue-43.md), [Issue 44](epic-1-issue-44.md)
## Ziel und Grenzen
Plattformübergreifende App-Abnahme als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: `native` (entsteht ab Issue 36); [resources/js](../../resources/js). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. macOS, Android und iOS/iPadOS mit fertigem Webfrontend prüfen.
2. Serverwechsel, Sessionablauf, Serverupdate, Verbindungsabbruch, Links und Dateiauswahl abnehmen.
3. Dokumentierte reproduzierbare Befunde mit klaren Reproduktionsschritten festhalten.
## Akzeptanzkriterien
- [ ] Alle vereinbarten App-Abläufe bestehen.
- [ ] Keine Produktionsdaten als Testfixtures verändert.
- [ ] Releasewege entsprechen Vereinbarung.
- [ ] Größere Defekte werden nicht im Abnahmeticket versteckt.
## Prüfung
Zunächst den im Ticket genannten Plattformbuild und die beschriebenen Abläufe prüfen. Für neue App-Logik gezielte Tests für Navigation, Zustände oder URL-Verarbeitung ergänzen; Plattformverhalten im passenden Simulator bzw. auf einem geeigneten Gerät prüfen. Bei Vue/TypeScript-Änderungen abschließend `npm run types:check`; bei Backend-Anpassungen betroffene PHPUnit-Tests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+35
View File
@@ -0,0 +1,35 @@
# UI Rework 5: Dialoge, Rückmeldungen und Eingabeschutz
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 3](epic-1-issue-3.md), [Issue 4](epic-1-issue-4.md)
## Ziel und Grenzen
Dialoge, Rückmeldungen und Eingabeschutz als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/components](../../resources/js/components); [resources/js/composables](../../resources/js/composables). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Dialoge, Menüs, Leer-, Lade- und Fehlerzustände vereinheitlichen.
2. Formfehler und Wiederholaktionen definieren.
3. Ungespeicherte Eingaben gegen Navigation und fehlgeschlagene Requests absichern.
## Akzeptanzkriterien
- [ ] Keine verlorenen Eingaben bei Requestfehlern.
- [ ] Dialogfokus wird korrekt zurückgegeben.
- [ ] Netzwerkfehler und serverseitige Validierung sind unterscheidbar.
## Prüfung
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 6: Anmeldung, E-Mail-Verifikation und 2FA-Challenge
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
## Ziel und Grenzen
Anmeldung, E-Mail-Verifikation und 2FA-Challenge als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/auth/Login.vue](../../resources/js/pages/auth/Login.vue); [resources/js/pages/auth/VerifyEmail.vue](../../resources/js/pages/auth/VerifyEmail.vue); [resources/js/pages/auth/TwoFactorChallenge.vue](../../resources/js/pages/auth/TwoFactorChallenge.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Die drei Ansichten vollständig neu erstellen.
2. Bestehende Fortify-Routen und Sessionflüsse verwenden.
3. Mobile Tastatur, Autofill und Recovery-Codes berücksichtigen.
## Akzeptanzkriterien
- [ ] Normale und zweistufige Anmeldung funktionieren.
- [ ] Fehler erhalten passende Eingaben.
- [ ] Bestehende Autorisierungsregeln bleiben wirksam.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Auth/AuthenticationTest.php](../../tests/Feature/Auth/AuthenticationTest.php), [tests/Feature/Auth/TwoFactorChallengeTest.php](../../tests/Feature/Auth/TwoFactorChallengeTest.php), [tests/Feature/Auth/EmailVerificationTest.php](../../tests/Feature/Auth/EmailVerificationTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 7: Passwort- und Bestätigungsseiten
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 6](epic-1-issue-6.md)
## Ziel und Grenzen
Passwort- und Bestätigungsseiten als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/auth/ForgotPassword.vue](../../resources/js/pages/auth/ForgotPassword.vue); [resources/js/pages/auth/ResetPassword.vue](../../resources/js/pages/auth/ResetPassword.vue); [resources/js/pages/auth/ConfirmPassword.vue](../../resources/js/pages/auth/ConfirmPassword.vue); [resources/js/pages/auth/ForcePasswordChange.vue](../../resources/js/pages/auth/ForcePasswordChange.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Passwort-Reset, sensible Aktionsbestätigung und erzwungenen Passwortwechsel neu gestalten.
2. Bestehende Fortify-Verträge erhalten.
3. Zustände abgelaufener oder ungültiger Links berücksichtigen.
## Akzeptanzkriterien
- [ ] Alle vier Flüsse funktionieren mit bestehenden Endpunkten.
- [ ] Sensible Werte werden nicht in Entwürfen gespeichert.
- [ ] Erfolg und Fehler sind klar.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Auth/PasswordResetTest.php](../../tests/Feature/Auth/PasswordResetTest.php), [tests/Feature/Auth/PasswordConfirmationTest.php](../../tests/Feature/Auth/PasswordConfirmationTest.php), [tests/Feature/Settings/SecurityTest.php](../../tests/Feature/Settings/SecurityTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 8: Projektübersicht und Projekteinstieg
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 4](epic-1-issue-4.md), [Issue 5](epic-1-issue-5.md)
## Ziel und Grenzen
Projektübersicht und Projekteinstieg als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Index.vue](../../resources/js/pages/projects/Index.vue); [resources/js/pages/projects/Show.vue](../../resources/js/pages/projects/Show.vue). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Projektübersicht und Projekteinstieg neu aufbauen.
2. Erlaubte Aktionen und direkte Wege zu Board, Backlog und Einstellungen anbieten.
3. Vorhandene Resources nutzen.
## Akzeptanzkriterien
- [ ] Nur sichtbare Projekte erscheinen.
- [ ] Leerzustand und eingeschränkte Rechte sind abgedeckt.
- [ ] Keine Mockup-Funktionen ohne Backend.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+37
View File
@@ -0,0 +1,37 @@
# UI Rework 9: Projektanlage, Duplikation und Vorlagenauswahl
Epic: [Epic 1](epic-1.md)
Abhängigkeiten: [Issue 8](epic-1-issue-8.md)
## Ziel und Grenzen
Projektanlage, Duplikation und Vorlagenauswahl als abgegrenzten Teil des vollständigen Frontend-Neubaus umsetzen. Grundlage: Vue 3/Inertia 2 mit Laravel, Tauri 2 für Apps, gemeinsames Repository, servergeladene Oberfläche, HTTPS, Hell/Dunkel/System und englische Texte über Übersetzungsschlüssel. Bestehende Fachlogik bleibt maßgeblich; Resources und Routen nur soweit für diese Oberfläche erforderlich anpassen. Keine zusätzlichen Produktmodule.
## Einstieg
Gezielt prüfen: [resources/js/pages/projects/Create.vue](../../resources/js/pages/projects/Create.vue); [app/Http/Controllers/ProjectController.php](../../app/Http/Controllers/ProjectController.php). Die Pfade sind Einstiegspunkte, keine Erlaubnis zu fachlichen Umbauten. [AGENTS.md](../../AGENTS.md) und die für den tatsächlich geänderten Bereich geltenden Skills beachten. Vor Codeänderungen die zur installierten Version passende Dokumentation prüfen.
Design: [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015)
## Implementierungsweg
1. Neuen Erstellablauf für leeres Projekt, Duplikat und Vorlage bauen.
2. Bedingte Felder und Kopieroptionen aus dem Bestand übernehmen.
3. Backend-Verträge nur bei Darstellungsbedarf anpassen.
## Akzeptanzkriterien
- [ ] Alle drei vorhandenen Erstellwege bleiben korrekt.
- [ ] Validierung ist verständlich.
- [ ] Operative Ticketdaten werden weiterhin nicht dupliziert.
## Prüfung
Vorhandene gezielte Abdeckung: [tests/Feature/Projects/ProjectControllerTest.php](../../tests/Feature/Projects/ProjectControllerTest.php), [tests/Feature/Projects/ProjectTemplateControllerTest.php](../../tests/Feature/Projects/ProjectTemplateControllerTest.php). Nur bei Berührung des jeweiligen Verhaltens aktualisieren bzw. ausführen.
Gezielte Verhaltenstests für die Änderung; betroffene PHPUnit-Featuretests bei Controller-/Resource-/Route-Anpassungen. Oberfläche mit Tastatur und bei mobiler Breite in beiden Themes prüfen. Projektvorgaben für Lint/Build und bei Vue/TypeScript abschließend `npm run types:check` erfüllen. Keine ausschließlich optischen Momentaufnahmen als Ersatz für Verhaltenstests.
## Grenze der Agentensitzung
Nur dieses Ticket und bereits erfüllte Abhängigkeiten bearbeiten. Ein zusammenhängendes Ergebnis mit passenden Tests liefern; keine Nachbartickets mitziehen. Die Aufteilung zielt auf eine begrenzte Sitzung mit Reserve für Prüfung, garantiert aber kein festes Zeit- oder Tokenbudget. Wenn unerwartete Voraussetzungen den Umfang wesentlich vergrößern, rechtzeitig den Stand, betroffene Dateien, bereits ausgeführte Prüfungen und den konkret nötigen Folgeschritt festhalten.
+15
View File
@@ -0,0 +1,15 @@
# Epic 1: KanbanGit UI Rework und Tauri-Apps
## Scope
Die vorhandene, aus dem Laravel-Starterkit entstandene Oberfläche vollständig durch eine neue Vue/Inertia-Oberfläche im Stil von [Fluid Emerald Kanban](https://stitch.withgoogle.com/projects/9754281177402648015) ersetzen. Alle bestehenden Produktfunktionen erhalten ein neues, auf Desktop und Mobilgeräte abgestimmtes Erscheinungsbild.
Eine gemeinsame servergeladene Oberfläche versorgt Browser sowie Tauri-Apps für macOS, iOS/iPadOS und Android. Backend und Clients bleiben im selben Repository. Selbst gehostete Installationen werden über eine frei wählbare HTTPS-Adresse angebunden; jeweils eine Installation ist aktiv.
Zum Zielumfang gehören Hell/Dunkel/System, eine Lokalisierungsstruktur mit zunächst englischen Texten und bessere Frontend-Bedienung einschließlich Drag-and-drop für bestehende Statuswechsel. Anpassungen an Resources, Inertia-Daten und erforderlichen Routen sind zulässig.
Ausgeschlossen sind neue fachliche Backend-Funktionen, zusätzliche Mockup-Module, Offline-Datensynchronisierung, persistierte freie Kartenreihenfolge und Hintergrund-Push. macOS und Android werden als signierte Git-Release-Downloads vorbereitet; iOS/iPadOS wird zunächst selbst über Xcode gebaut. Stores folgen später.
## Abschluss
Die neue Oberfläche deckt die bestehenden Funktionen ab, funktioniert auf allen vereinbarten Plattformen und hat das alte Starterkit-Frontend vollständig abgelöst. Die Umsetzung ist in eigenständige, begrenzte Agentensitzungen aufgeteilt.
@@ -1,84 +0,0 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<article
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div v-if="$slots.actions" class="border-t p-4">
<slot name="actions" />
</div>
</article>
</template>
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<li
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
>
{{ ticket.title }}
</h3>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
>
<slot name="actions" />
</div>
</div>
</li>
</template>
@@ -1,91 +0,0 @@
<script setup lang="ts">
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketModel } from '@/types/models/ticket-model';
const props = withDefaults(
defineProps<{
showStatus?: boolean;
ticket: TicketModel;
}>(),
{
showStatus: true,
},
);
const { t } = useLocalization();
const statusLabel = computed(() =>
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
);
</script>
<template>
<div class="gap-stack flex min-w-0 flex-wrap items-center">
<Badge
v-if="ticket.is_blocked"
variant="warning"
:aria-label="t('tickets.blocked')"
>
<OctagonAlert aria-hidden="true" />
{{ t('tickets.blocked') }}
</Badge>
<Badge
variant="secondary"
:aria-label="
t('tickets.typeLabel', { name: ticket.ticket_type.name })
"
:data-ticket-type-id="ticket.ticket_type.id"
>
{{ ticket.ticket_type.name }}
</Badge>
<Badge
variant="outline"
:aria-label="
t('tickets.priorityLabel', {
name: ticket.ticket_priority.name,
})
"
:data-ticket-priority-id="ticket.ticket_priority.id"
>
{{ ticket.ticket_priority.name }}
</Badge>
<Badge
v-if="showStatus"
variant="outline"
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
:data-ticket-status-id="ticket.ticket_status?.id"
>
{{ statusLabel }}
</Badge>
<span
v-if="ticket.assignee"
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
:aria-label="
t('tickets.assignedTo', { name: ticket.assignee.name })
"
>
<UserAvatar
:user="ticket.assignee"
size="sm"
:label="ticket.assignee.name"
/>
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
</span>
<span
v-else
class="text-caption text-muted-foreground flex items-center gap-1.5"
>
<CircleUserRound class="size-4" aria-hidden="true" />
{{ t('tickets.unassigned') }}
</span>
</div>
</template>
-2
View File
@@ -1,2 +0,0 @@
export { default as TicketCard } from './TicketCard.vue';
export { default as TicketListItem } from './TicketListItem.vue';
-154
View File
@@ -1,154 +0,0 @@
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import type { TicketModel } from '@/types/models/ticket-model';
/** Pointer capture keeps mouse, pen and touch drags on the same code path. */
export function useBoardDrag(options: {
canStart: (ticket: TicketModel) => boolean;
drop: (ticket: TicketModel, columnId: number) => void;
}) {
const ticket = shallowRef<TicketModel | null>(null);
const active = ref(false);
const x = ref(0);
const y = ref(0);
const columnId = ref<number | null>(null);
let pointerId: number | null = null;
let handle: HTMLElement | null = null;
let startX = 0;
let startY = 0;
let frame = 0;
const draggedTicket = computed(() => (active.value ? ticket.value : null));
function locateTarget(): void {
const target = document
.elementFromPoint(x.value, y.value)
?.closest<HTMLElement>('[data-board-drop-column]');
columnId.value = target ? Number(target.dataset.boardDropColumn) : null;
}
function scroll(): void {
if (!active.value) {
return;
}
const element = document.elementFromPoint(x.value, y.value);
const region = element?.closest<HTMLElement>(
'[data-board-drag-scroll]',
);
if (region) {
const bounds = region.getBoundingClientRect();
const left = Math.max(0, bounds.left);
const right = Math.min(window.innerWidth, bounds.right);
const delta =
x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0;
region.scrollLeft += delta;
}
if (!element?.closest('[data-board-mobile-targets]')) {
const delta =
y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0;
window.scrollBy(0, delta);
}
locateTarget();
frame = requestAnimationFrame(scroll);
}
function cancel(): void {
const previousPointer = pointerId;
pointerId = null;
active.value = false;
ticket.value = null;
columnId.value = null;
cancelAnimationFrame(frame);
if (
previousPointer !== null &&
handle?.hasPointerCapture(previousPointer)
) {
handle.releasePointerCapture(previousPointer);
}
handle = null;
}
function start(event: PointerEvent, source: TicketModel): void {
if (
pointerId !== null ||
!event.isPrimary ||
event.button !== 0 ||
!options.canStart(source)
) {
return;
}
handle = event.currentTarget as HTMLElement;
handle.focus({ preventScroll: true });
handle.setPointerCapture(event.pointerId);
pointerId = event.pointerId;
ticket.value = source;
startX = x.value = event.clientX;
startY = y.value = event.clientY;
}
function move(event: PointerEvent): void {
if (event.pointerId !== pointerId) {
return;
}
x.value = event.clientX;
y.value = event.clientY;
if (
!active.value &&
Math.hypot(x.value - startX, y.value - startY) >= 6
) {
active.value = true;
frame = requestAnimationFrame(scroll);
}
if (active.value) {
event.preventDefault();
locateTarget();
}
}
function finish(event: PointerEvent): void {
if (event.pointerId !== pointerId) {
return;
}
x.value = event.clientX;
y.value = event.clientY;
locateTarget();
const source = draggedTicket.value;
const target = columnId.value;
cancel();
if (source && target !== null) {
options.drop(source, target);
}
}
function cancelPointer(event: PointerEvent): void {
if (event.pointerId === pointerId) {
cancel();
}
}
function keydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && pointerId !== null) {
event.preventDefault();
cancel();
}
}
onMounted(() => {
document.addEventListener('pointermove', move, { passive: false });
document.addEventListener('pointerup', finish);
document.addEventListener('pointercancel', cancelPointer);
document.addEventListener('lostpointercapture', cancelPointer);
document.addEventListener('keydown', keydown);
window.addEventListener('blur', cancel);
});
onUnmounted(() => {
cancel();
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', finish);
document.removeEventListener('pointercancel', cancelPointer);
document.removeEventListener('lostpointercapture', cancelPointer);
document.removeEventListener('keydown', keydown);
window.removeEventListener('blur', cancel);
});
return { draggedTicket, columnId, x, y, start, cancel };
}
-20
View File
@@ -1,20 +0,0 @@
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export function ticketStatusLabel(
ticketStatus: TicketStatusModel | null,
fallback: string,
): string {
if (ticketStatus === null) {
return fallback;
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined ||
ticketStatus.board_column.name === ticketStatus.name
) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
}
-178
View File
@@ -1,173 +1,4 @@
export const en = {
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
'board.columns': 'Workflow columns',
'board.selectColumn': 'Choose a column',
'board.columnOption': '{name} ({count})',
'board.scrollHint': 'Scroll horizontally to explore all columns.',
'board.openBacklog': 'Open backlog',
'board.backToProject': 'Project overview',
'board.empty.title': 'No workflow columns yet',
'board.empty.description':
'Set up columns to start tracking work on this board.',
'board.emptyColumn': 'No tickets in this column.',
'board.emptyStatus': 'No tickets in this status.',
'board.noStatuses': 'No statuses configured for this column.',
'board.moveTicket': 'Move {key}',
'board.updateStatus': 'Update status',
'board.updatingStatus': 'Updating…',
'board.noMoves': 'No allowed moves',
'board.drag': 'Drag to move',
'board.dragTicket': 'Drag {key} to change status',
'board.dragHint':
'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.',
'board.dropHint': 'Drop on a column. Hold near an edge to scroll.',
'board.dragging':
'Dragging {key}. Drop on an allowed column or press Escape to cancel.',
'board.chooseStatus': 'Choose a destination status',
'board.chooseStatusDescription':
'More than one status is available for {key} in this column.',
'board.targetStatus': 'Destination status',
'board.noMovesDescription':
'This status does not allow moving anywhere else.',
'projectCreation.title': 'Create a project',
'projectCreation.description':
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
'projectCreation.start': 'How would you like to start?',
'projectCreation.scratch': 'Start from scratch',
'projectCreation.scratchDescription':
'Create an empty project with the default setup.',
'projectCreation.duplicate': 'Duplicate a project',
'projectCreation.duplicateDescription':
'Choose an existing project and copy selected settings.',
'projectCreation.template': 'Use a template',
'projectCreation.templateDescription':
'Start with a saved, reusable project configuration.',
'projectCreation.noProjects':
'No accessible projects are available to copy yet.',
'projectCreation.noTemplates': 'No published templates are available yet.',
'projectCreation.details': 'Project details',
'projectCreation.name': 'Project name',
'projectCreation.namePlaceholder': 'Platform engineering',
'projectCreation.key': 'Project key',
'projectCreation.keyPlaceholder': 'CORE',
'projectCreation.keyHelp':
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
'projectCreation.copyTitle': 'Choose what to copy',
'projectCreation.copyHelp':
'Copy configuration into an independent project. Select only what you need.',
'projectCreation.sourceProject': 'Source project',
'projectCreation.selectProject': 'Select a source project',
'projectCreation.copyOptions': 'Configuration areas',
'projectCreation.copyDefaults':
'Unselected areas keep the default setup. You can also continue without copying any settings.',
'projectCreation.chooseTemplate': 'Choose a template',
'projectCreation.templateLabel': 'Template',
'projectCreation.selectTemplate': 'Select a template',
'projectCreation.templateHelp':
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
'projectCreation.includedAreas': 'Included configuration',
'projectCreation.templateDefaults':
'This template uses the default project setup.',
'projectCreation.summary': 'Your new project',
'projectCreation.untitled': 'Add a project name',
'projectCreation.ticketPreview': 'First ticket key',
'projectCreation.setup': 'Starting point',
'projectCreation.cleanStart':
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
'projectCreation.submit': 'Create project',
'projectCreation.creating': 'Creating project…',
'projectCreation.areas.projectSettings.label': 'Project settings',
'projectCreation.areas.projectSettings.description':
'Story points and author visibility.',
'projectCreation.areas.boardColumns.label': 'Board columns',
'projectCreation.areas.boardColumns.description':
'Columns and their order on the board.',
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
'projectCreation.areas.ticketStatuses.description':
'Statuses and their board columns.',
'projectCreation.areas.statusTransitions.label': 'Status transitions',
'projectCreation.areas.statusTransitions.description':
'Allowed moves between statuses.',
'projectCreation.areas.ticketTypes.label': 'Ticket types',
'projectCreation.areas.ticketTypes.description':
'Available types, including parent types.',
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
'projectCreation.areas.ticketPriorities.description':
'Available priority levels.',
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
'projectCreation.areas.ticketFields.description':
'Field definitions without ticket values.',
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
'projectCreation.areas.ticketLinkTypes.description':
'Relationship labels between tickets.',
'projectCreation.areas.repositories.label': 'Repository connections',
'projectCreation.areas.repositories.description':
'Repositories linked to the project.',
'projectCreation.areas.notifications.label': 'Notification actions',
'projectCreation.areas.notifications.description':
'Notification rules and delivery settings.',
'projectCreation.selectedCount': {
one: '{count} area selected',
other: '{count} areas selected',
},
'projects.workspace': 'Workspace',
'projects.description':
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
'projects.create': 'New project',
'projects.updated': 'Project update',
'projects.available': {
one: '{count} project available',
other: '{count} projects available',
},
'projects.open': 'Open {name} overview',
'projects.openBoard': 'Open {name} board',
'projects.openBacklog': 'Open {name} backlog',
'projects.overview': 'Project overview',
'projects.empty.title': 'No projects to show',
'projects.empty.createDescription':
'Create a project to give your team a place to plan and track work.',
'projects.empty.accessDescription':
'Projects appear here when you have access. Ask a workspace or project administrator to add you.',
'projects.empty.create': 'Create your first project',
'projects.back': 'All projects',
'projects.hubDescription':
'Follow work on the board or explore tickets in the backlog.',
'projects.workAreas': 'Project work areas',
'projects.boardDescription':
'See work across the project’s workflow and open tickets in their current status.',
'projects.backlogDescription':
'Explore incoming work, review ticket details, and plan what comes next.',
'projects.boardAction': 'Open board',
'projects.backlogAction': 'Open backlog',
'projects.settingsDescription':
'Manage this project’s configuration, integrations, and access.',
'projects.saveTemplate': 'Save as new template',
'projects.templateTitle': 'Based on {name}',
'projects.templateDescription':
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
'projects.inheritedArea': '{name} · inherited',
'projects.overriddenArea': '{name} · overridden',
'dashboard.eyebrow': 'Personal workspace',
'dashboard.greeting': 'Welcome back, {name}',
'dashboard.description':
'A current view of tickets assigned to you in projects you can access.',
'dashboard.summary': 'Work summary',
'dashboard.summary.assignedTickets': 'Assigned tickets',
'dashboard.summary.projects': 'Projects with assigned work',
'dashboard.summary.blockedTickets': 'Blocked tickets',
'dashboard.assignedWork': 'My assigned tickets',
'dashboard.assignedWorkDescription':
'Your work is grouped by project and ordered with the newest tickets first.',
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
'dashboard.openProject': 'Open {name}',
'dashboard.openProjectAction': 'Open project',
'dashboard.empty.title': 'No tickets assigned to you',
'dashboard.empty.description':
'Tickets assigned to you in projects you can access will appear here.',
'dashboard.empty.action': 'Browse projects',
'auth.home': 'KanbanGit home',
'auth.accountAccess': 'Account access',
'auth.backToLogin': 'Return to sign in',
@@ -347,15 +178,6 @@ export const en = {
'tickets.noPullRequests': 'No matching pull requests',
'tickets.noPullRequestsDescription':
'No matching pull requests were found across the linked repositories.',
'tickets.open': 'Open {key}: {title}',
'tickets.blocked': 'Blocked',
'tickets.blockerComment': 'Blocker: {comment}',
'tickets.typeLabel': 'Type: {name}',
'tickets.priorityLabel': 'Priority: {name}',
'tickets.statusLabel': 'Status: {name}',
'tickets.noStatus': 'No status',
'tickets.assignedTo': 'Assigned to {name}',
'tickets.unassigned': 'Unassigned',
'common.save': 'Save',
'common.loading': 'Loading…',
'common.error': 'Something went wrong.',
+32 -196
View File
@@ -1,211 +1,47 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index';
import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
import type { BreadcrumbItem } from '@/types';
const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
const breadcrumbs: BreadcrumbItem[] = [
{
label: t('dashboard.summary.assignedTickets'),
value: assignedTickets.value.length,
icon: ListTodo,
title: 'Dashboard',
href: dashboard(),
},
{
label: t('dashboard.summary.projects'),
value: props.assignedTicketsByProject.length,
icon: FolderGit2,
},
{
label: t('dashboard.summary.blockedTickets'),
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
.length,
icon: OctagonAlert,
},
]);
];
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('navigation.dashboard')" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('dashboard.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline break-words">
{{
t('dashboard.greeting', {
name: $page.props.auth.user.name,
})
}}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.description') }}
</p>
</header>
<section :aria-label="t('dashboard.summary')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
>
<header class="gap-stack grid min-w-0">
<h2 id="assigned-work-title" class="text-title">
{{ t('dashboard.assignedWork') }}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.assignedWorkDescription') }}
</p>
</header>
<FeedbackState
v-if="assignedTicketsByProject.length === 0"
state="empty"
:title="t('dashboard.empty.title')"
:description="t('dashboard.empty.description')"
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
<PlaceholderPattern />
</div>
</section>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
>
<PlaceholderPattern />
</div>
</div>
</AppLayout>
</template>
+169 -84
View File
@@ -2,7 +2,6 @@
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { TicketListItem } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -13,11 +12,13 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -39,6 +40,25 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
@@ -112,98 +132,163 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
No backlog tickets exist yet.
</div>
<ul v-else class="gap-column grid p-4">
<TicketListItem
v-for="ticket in tickets"
:key="ticket.id"
:project-id="project.id"
:ticket="ticket"
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex w-full flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${ticket.id}`"
class="text-label text-muted-foreground uppercase"
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">Key</th>
<th class="px-4 py-3 font-medium">Title</th>
<th class="px-4 py-3 font-medium">Type</th>
<th class="px-4 py-3 font-medium">
Status
</th>
<th class="px-4 py-3 font-medium">
Priority
</th>
<th
v-if="canEditTickets"
class="px-4 py-3 font-medium"
>
Move to status
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`backlog-ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(ticket)
.length === 0 || processing
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="ticket in tickets"
:key="ticket.id"
class="bg-background"
>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
</Link>
</td>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ ticket.title }}
</Link>
<div
v-if="ticket.description"
class="text-muted-foreground mt-1 line-clamp-2"
>
{{ ticket.description }}
</div>
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_type.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_priority.name }}
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-bind="
ProjectBoardController.move.form(
{
project: project.id,
ticket: ticket.id,
},
)
"
class="flex flex-col gap-2 sm:flex-row"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<select
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
<p
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
class="text-muted-foreground text-xs sm:basis-full"
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
'Unknown status',
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(ticket)
.length === 0
"
>
Move
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</template>
</TicketListItem>
</ul>
This status does not allow
moving anywhere else.
</p>
</Form>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
+276 -615
View File
@@ -1,248 +1,91 @@
<script setup lang="ts">
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useBoardDrag } from '@/composables/useBoardDrag';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectBoardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.board'), href: boardIndex(props.project.id) },
]);
const selectedColumnId = ref<number>();
watch(
() =>
[
props.project.id,
props.boardColumns.map((column) => column.id),
] as const,
([projectId, columnIds], previous) => {
if (
projectId !== previous?.[0] ||
!columnIds.includes(selectedColumnId.value ?? -1)
) {
selectedColumnId.value = columnIds[0];
}
},
{ immediate: true },
);
const columns = computed(() =>
props.boardColumns.map((column) => {
const ticketsByStatus = new Map<number | null, TicketModel[]>();
for (const ticket of column.tickets ?? []) {
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
tickets.push(ticket);
ticketsByStatus.set(ticket.ticket_status_id, tickets);
}
return {
...column,
count: column.tickets?.length ?? 0,
groups: (column.ticket_statuses ?? []).map((status) => ({
status,
tickets: ticketsByStatus.get(status.id) ?? [],
})),
};
}),
);
const ticketCount = computed(() =>
columns.value.reduce((count, column) => count + column.count, 0),
);
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' });
const feedback = useFormProtection(moveForm, { isDirty: () => false });
const menuPending = ref<number | null>(null);
const busy = computed(() => moveForm.processing || menuPending.value !== null);
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
const findTicket = (id: number) =>
props.boardColumns
.flatMap((column) => column.tickets ?? [])
.find((ticket) => ticket.id === id);
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
const statusIds =
props.boardColumns
.find((column) => column.id === columnId)
?.ticket_statuses?.map((status) => status.id) ?? [];
return allowedTargetStatuses(ticket).filter((status) =>
statusIds.includes(status.id),
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectIndex(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board',
href: boardIndex(props.project.id),
},
];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
);
};
const choiceTicket = computed(() =>
choice.value ? findTicket(choice.value.ticketId) : undefined,
);
const choiceTargets = computed(() =>
choice.value && choiceTicket.value
? targetsForColumn(choiceTicket.value, choice.value.columnId)
: [],
);
const choiceOpen = computed({
get: () => choice.value !== null,
set: (open: boolean) => {
if (!open) {
choice.value = null;
}
},
});
function submitMove(ticketId: number, statusId: number): void {
const ticket = findTicket(ticketId);
if (
busy.value ||
!props.canEditTickets ||
!ticket ||
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
) {
return;
}
lastMove.value = { ticketId, statusId };
moveForm.ticket_status_id = statusId;
moveForm.clearErrors();
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
errorBag: `board-ticket-${ticketId}`,
preserveScroll: true,
onSuccess: async () => {
const destination = props.boardColumns.find((column) =>
column.ticket_statuses?.some(
(status) => status.id === statusId,
),
);
if (destination) {
selectedColumnId.value = destination.id;
}
await nextTick();
document
.getElementById(`ticket-status-${ticketId}`)
?.focus({ preventScroll: true });
},
});
}
function dropTicket(ticket: TicketModel, columnId: number): void {
const current = findTicket(ticket.id);
if (!current || busy.value || !props.canEditTickets) {
return;
}
const targets = targetsForColumn(current, columnId);
if (targets.length === 1) {
submitMove(current.id, targets[0].id);
} else if (targets.length > 1) {
moveForm.ticket_status_id = targets[0].id;
choice.value = { ticketId: current.id, columnId };
}
}
const {
draggedTicket,
columnId: dropColumnId,
x: dragX,
y: dragY,
start: startDrag,
cancel: cancelDrag,
} = useBoardDrag({
canStart: (ticket) =>
props.canEditTickets &&
!busy.value &&
!choice.value &&
allowedTargetStatuses(ticket).length > 0,
drop: dropTicket,
});
const canDrop = (columnId: number) =>
!!draggedTicket.value &&
targetsForColumn(draggedTicket.value, columnId).length > 0;
const dropClasses = (columnId: number) =>
!draggedTicket.value
? {}
: {
'border-primary border-dashed': canDrop(columnId),
'ring-2 ring-primary bg-primary/10':
canDrop(columnId) && dropColumnId.value === columnId,
'opacity-40': !canDrop(columnId),
};
function confirmMove(): void {
if (
!choice.value ||
!choiceTargets.value.some(
(status) => status.id === moveForm.ticket_status_id,
)
) {
return;
}
const ticketId = choice.value.ticketId;
choice.value = null;
submitMove(ticketId, moveForm.ticket_status_id);
}
function focusStatus(ticketId: number): void {
document.getElementById(`ticket-status-${ticketId}`)?.focus();
}
watch(
() => [props.project.id, props.boardColumns, props.canEditTickets],
() => {
cancelDrag();
choice.value = null;
},
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('board.title', { name: project.name })" />
<Head :title="`${project.name} board`" />
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
<header
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>{{ project.key }}</Badge
>
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: ticketCount }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('board.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('board.description') }}
</p>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} board`"
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
/>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
@@ -252,427 +95,245 @@ watch(
)
"
>
<Plus aria-hidden="true" />{{
t('navigation.createTicket')
}}
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
><ListTodo aria-hidden="true" />{{
t('board.openBacklog')
}}</Link
>Open backlog</Link
>
</Button>
<Button as-child variant="ghost">
<Link :href="show(project.id)"
><ArrowLeft aria-hidden="true" />{{
t('board.backToProject')
}}</Link
>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</header>
</div>
<FeedbackState v-if="status" state="success" :title="status" />
<FormFeedback
:state="feedback.state.value"
:description="moveForm.errors.ticket_status_id"
@retry="
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
"
/>
<FeedbackState
v-if="columns.length === 0"
state="empty"
:title="t('board.empty.title')"
:description="t('board.empty.description')"
/>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<template v-else>
<div class="grid min-w-0 gap-2 md:hidden">
<label for="board-column" class="text-body font-semibold">{{
t('board.selectColumn')
}}</label>
<select
id="board-column"
v-model="selectedColumnId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
:aria-controls="`board-column-${selectedColumnId}`"
>
<option
v-for="column in columns"
:key="column.id"
:value="column.id"
>
{{
t('board.columnOption', {
name: column.name,
count: column.count,
})
}}
</option>
</select>
</div>
<p
id="board-scroll-hint"
class="text-caption text-muted-foreground hidden md:block"
<Card v-if="boardColumns.length === 0">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
The board has no workflow columns yet, so no tickets can
appear here.
</p>
<div v-if="canEditTickets">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Open board settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<div v-else class="grid gap-4 xl:grid-cols-3">
<Card
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="flex h-full flex-col gap-0 overflow-hidden"
>
{{ t('board.scrollHint') }}
</p>
<p
v-if="canEditTickets"
id="board-drag-hint"
class="text-caption text-muted-foreground"
>
{{ t('board.dragHint') }}
</p>
<section
:aria-label="t('board.columns')"
aria-describedby="board-scroll-hint"
tabindex="0"
data-board-drag-scroll
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
>
<section
v-for="column in columns"
:id="`board-column-${column.id}`"
:key="`${project.id}-${column.id}`"
:aria-labelledby="`board-column-title-${column.id}`"
:data-board-drop-column="column.id"
:class="[
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
selectedColumnId === column.id ? 'flex' : 'hidden',
dropClasses(column.id),
]"
>
<header
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
>
<h2
:id="`board-column-title-${column.id}`"
class="text-title wrap-anywhere"
>
{{ column.name }}
</h2>
<Badge
variant="secondary"
class="shrink-0 tabular-nums"
:aria-label="
t('tickets.count', { count: column.count })
"
>{{ column.count }}</Badge
>
</header>
<p
v-if="column.count === 0"
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
>
{{ t('board.emptyColumn') }}
</p>
<p
v-if="column.groups.length === 0"
class="text-caption text-muted-foreground"
>
{{ t('board.noStatuses') }}
</p>
<section
v-for="group in column.groups"
:key="group.status.id"
:aria-labelledby="`board-status-${group.status.id}`"
class="grid min-w-0 gap-3"
>
<div
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
>
<h3
:id="`board-status-${group.status.id}`"
class="wrap-anywhere font-semibold"
>
{{ group.status.name }}
</h3>
<span
class="shrink-0 tabular-nums"
:aria-label="
t('tickets.count', {
count: group.tickets.length,
})
<CardHeader class="bg-muted/20 border-b">
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
<CardDescription>
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
>{{ group.tickets.length }}</span
class="mt-3 flex flex-wrap gap-2"
>
</div>
<p
v-if="group.tickets.length === 0"
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
>
{{ t('board.emptyStatus') }}
</p>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
:class="{
'opacity-50':
draggedTicket?.id === ticket.id,
}"
>
<TicketCard
:project-id="project.id"
:ticket="ticket"
:show-status="false"
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
<template
v-if="canEditTickets"
#actions
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<Badge variant="secondary">
#{{ boardColumn.position }}
</Badge>
</div>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-4 p-4">
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No tickets are currently in this column.
</div>
<div v-else class="flex flex-col gap-4">
<Card
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'gap-0',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
>
<CardHeader class="pb-4">
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
>
<Button
type="button"
variant="ghost"
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
Blocked
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="hover:text-primary transition"
>
{{ ticket.title }}
</Link>
</CardTitle>
<CardDescription
v-if="ticket.description"
class="line-clamp-3"
>
{{ ticket.description }}
</CardDescription>
<CardDescription
v-if="
ticket.is_blocked &&
ticket.blocked_comment
"
class="mt-2 text-amber-900 dark:text-amber-200"
>
{{ ticket.blocked_comment }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
>
Move ticket
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
busy ||
allowedTargetStatuses(
ticket,
).length === 0
).length === 0 || processing
"
:aria-label="
t('board.dragTicket', {
key: ticket.ticket_key,
})
"
aria-describedby="board-drag-hint"
@pointerdown="
startDrag($event, ticket)
"
@click.prevent
@keydown.enter.prevent="
focusStatus(ticket.id)
"
@keydown.space.prevent="
focusStatus(ticket.id)
"
><GripVertical
aria-hidden="true"
/>{{ t('board.drag') }}</Button
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<Form
v-bind="
move.form({
project: project.id,
ticket: ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`board-ticket-${ticket.id}`"
:options="{
preserveScroll: true,
}"
:on-before="
() =>
!busy && !draggedTicket
"
@start="menuPending = ticket.id"
@finish="menuPending = null"
class="grid min-w-0 gap-2"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>{{
t('board.moveTicket', {
key: ticket.ticket_key,
})
}}</label
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
:aria-invalid="
!!errors.ticket_status_id ||
undefined
"
:aria-describedby="
errors.ticket_status_id
? `ticket-status-error-${ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t(
'tickets.noStatus',
),
)
}}
</option>
</select>
<InputError
:id="`ticket-status-error-${ticket.id}`"
:message="
errors.ticket_status_id
"
/>
<Button
type="submit"
variant="outline"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}</Button
>
<p
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
class="text-caption text-muted-foreground"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
t(
'board.noMovesDescription',
ticketStatusLabel(
ticketStatus,
)
}}
</p>
</Form>
</template>
</TicketCard>
</li>
</ul>
</section>
</section>
</section>
</template>
<div
v-if="draggedTicket"
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
data-board-mobile-targets
>
<p class="text-caption mb-2 font-semibold">
{{ t('board.dropHint') }}
</p>
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
<div
v-for="column in columns"
:key="column.id"
:data-board-drop-column="column.id"
:class="dropClasses(column.id)"
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
>
{{ column.name }}
</div>
</div>
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Update status
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</div>
<div
v-if="draggedTicket"
aria-hidden="true"
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
:style="{
left: `${Math.max(0, dragX - 80)}px`,
top: `${Math.max(0, dragY - 56)}px`,
}"
>
{{ draggedTicket.ticket_key }}
</div>
<p class="sr-only" aria-live="polite">
{{
draggedTicket
? t('board.dragging', { key: draggedTicket.ticket_key })
: ''
}}
</p>
<Dialog v-model:open="choiceOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
<DialogDescription>{{
t('board.chooseStatusDescription', {
key: choiceTicket?.ticket_key ?? '',
})
}}</DialogDescription>
</DialogHeader>
<form class="grid gap-4" @submit.prevent="confirmMove">
<label
for="board-drop-status"
class="text-body font-semibold"
>{{ t('board.targetStatus') }}</label
>
<select
id="board-drop-status"
v-model="moveForm.ticket_status_id"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option
v-for="target in choiceTargets"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="choiceOpen = false"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="busy || choiceTargets.length === 0"
>{{ t('board.updateStatus') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</AppLayout>
</template>
+278 -562
View File
@@ -1,633 +1,349 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import {
ArrowLeft,
Check,
Copy,
FolderPlus,
LayoutTemplate,
} from 'lucide-vue-next';
import { computed, watch } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import { computed } from 'vue';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/projects';
import { create, index } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
const props = defineProps<ProjectsCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: t('projects.create'), href: create() },
]);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: 'New project',
href: create(),
},
];
const defaultTemplateId =
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm(
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
{
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
name: '',
key: '',
source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id)
: '',
source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '',
copy_areas: [] as string[],
},
);
const protection = useFormProtection(form);
const modes = computed(() => [
{
value: 'scratch',
icon: FolderPlus,
title: t('projectCreation.scratch'),
description: t('projectCreation.scratchDescription'),
disabled: false,
},
{
value: 'duplicate',
icon: Copy,
title: t('projectCreation.duplicate'),
description: t(
props.sourceProjects.length
? 'projectCreation.duplicateDescription'
: 'projectCreation.noProjects',
),
disabled: props.sourceProjects.length === 0,
},
{
value: 'template',
icon: LayoutTemplate,
title: t('projectCreation.template'),
description: t(
props.sourceTemplates.length
? 'projectCreation.templateDescription'
: 'projectCreation.noTemplates',
),
disabled: props.sourceTemplates.length === 0,
},
]);
const areaKeys = {
project_settings: 'projectCreation.areas.projectSettings',
board_columns: 'projectCreation.areas.boardColumns',
ticket_statuses: 'projectCreation.areas.ticketStatuses',
status_transitions: 'projectCreation.areas.statusTransitions',
ticket_types: 'projectCreation.areas.ticketTypes',
ticket_priorities: 'projectCreation.areas.ticketPriorities',
ticket_fields: 'projectCreation.areas.ticketFields',
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
repository_connections: 'projectCreation.areas.repositories',
notification_actions: 'projectCreation.areas.notifications',
} as const;
function areaText(value: string, part: 'label' | 'description'): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key
? t(`${key}.${part}`)
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
value);
}
const selectedProject = computed(() =>
props.sourceProjects.find(
(project) => String(project.id) === form.source_project_id,
),
);
const selectedTemplate = computed(() =>
props.sourceTemplates.find(
(template) => String(template.id) === form.source_template_id,
),
);
const selectedMode = computed(() =>
modes.value.find((mode) => mode.value === form.creation_mode),
);
const ticketKey = computed(
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
);
const copyAreasError = computed(
() =>
const form = useForm({
copy_areas: [] as string[],
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '',
name: '',
source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id)
: '',
source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '',
});
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[1],
);
const selectClass =
'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
)?.[1] ??
''
);
});
watch(
() => form.creation_mode,
() => {
form.clearErrors(
'creation_mode',
'source_project_id',
'source_template_id',
'copy_areas',
);
for (const key of Object.keys(form.errors)) {
if (key.startsWith('copy_areas.')) {
form.clearErrors(key as 'copy_areas');
}
}
protection.clearFeedback();
},
);
const submit = (): void => {
if (form.creation_mode !== 'duplicate') {
form.source_project_id = '';
form.copy_areas = [];
}
function submit(): void {
form.transform((data) => ({
...data,
source_project_id:
data.creation_mode === 'duplicate' ? data.source_project_id : '',
source_template_id:
data.creation_mode === 'template' ? data.source_template_id : '',
copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
}));
protection.submit(store(), { preserveScroll: true });
}
if (form.creation_mode !== 'template') {
form.source_template_id = '';
}
form.post(ProjectController.store.url(), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('projects.create')" />
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
>
<header class="gap-stack grid">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('projectCreation.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projectCreation.description') }}
</p>
</header>
<Head title="New project" />
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback
:state="protection.state.value"
:title="
form.processing
? t('projectCreation.creating')
: undefined
"
@retry="submit"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
aria-describedby="creation-mode-error"
>
<legend class="text-title mb-3">
{{ t('projectCreation.start') }}
</legend>
<div class="gap-column grid md:grid-cols-3">
<label
v-for="mode in modes"
:key="mode.value"
class="relative min-w-0"
>
<input
v-model="form.creation_mode"
type="radio"
name="creation_mode"
:value="mode.value"
:disabled="mode.disabled"
:aria-invalid="!!form.errors.creation_mode"
:aria-labelledby="`mode-${mode.value}-title`"
:aria-describedby="`mode-${mode.value}-description`"
class="peer sr-only"
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<Heading
title="Create project"
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
/>
<Card>
<CardHeader>
<CardTitle>Project details</CardTitle>
<CardDescription>
The key is uppercased automatically, must be unique, and
can contain up to 5 characters.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<div class="grid gap-3">
<Label>How do you want to start?</Label>
<div class="grid gap-3 sm:grid-cols-3">
<Label
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.creation_mode"
type="radio"
value="scratch"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
Create from scratch
</div>
<p
class="text-muted-foreground text-sm"
>
Start with the default project
setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceProjects
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="duplicate"
class="mt-1"
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">
Create from existing project
</div>
<p
class="text-muted-foreground text-sm"
>
Copy only the settings you
explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="template"
class="mt-1"
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">
Create from template
</div>
<p
class="text-muted-foreground text-sm"
>
Reuse a saved configuration
template.
</p>
</div>
</Label>
</div>
</div>
<div class="grid gap-2">
<Label for="name">Project name</Label>
<Input
id="name"
v-model="form.name"
required
autofocus
placeholder="Kanban foundation"
/>
<span
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
:class="
!mode.disabled
? 'hover:border-primary/50'
: ''
"
>
<span
class="flex items-center justify-between gap-3"
>
<component
:is="mode.icon"
class="text-primary size-5"
aria-hidden="true"
/>
<Check
v-if="form.creation_mode === mode.value"
class="text-primary size-4"
aria-hidden="true"
/>
</span>
<span
:id="`mode-${mode.value}-title`"
class="font-semibold"
>{{ mode.title }}</span
>
<span
:id="`mode-${mode.value}-description`"
class="text-caption text-muted-foreground"
>{{ mode.description }}</span
>
</span>
</label>
</div>
<InputError
id="creation-mode-error"
:message="form.errors.creation_mode"
/>
</fieldset>
<InputError :message="form.errors.name" />
</div>
<div
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
>
<div class="gap-page grid min-w-0">
<section
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="project-details-title"
>
<h2 id="project-details-title" class="text-title">
{{ t('projectCreation.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{
t('projectCreation.name')
}}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing"
:placeholder="
t('projectCreation.namePlaceholder')
"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="key">{{
t('projectCreation.key')
}}</Label>
<Input
id="key"
v-model="form.key"
name="key"
required
maxlength="5"
:disabled="form.processing"
:placeholder="
t('projectCreation.keyPlaceholder')
"
class="uppercase sm:max-w-48"
:invalid="!!form.errors.key"
aria-describedby="key-help key-error"
autocapitalize="characters"
:spellcheck="false"
/>
<p
id="key-help"
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.keyHelp') }}
</p>
<InputError
id="key-error"
:message="form.errors.key"
/>
</div>
</section>
<div class="grid gap-2">
<Label for="key">Project key</Label>
<Input
id="key"
v-model="form.key"
required
maxlength="5"
placeholder="CORE"
class="uppercase"
/>
<p class="text-muted-foreground text-sm">
Example ticket keys will look like
<span class="text-foreground font-medium"
>CORE-1</span
>.
</p>
<InputError :message="form.errors.key" />
</div>
<section
<div
v-if="form.creation_mode === 'duplicate'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="copy-title"
class="space-y-6 rounded-xl border border-dashed p-4"
>
<div class="gap-stack grid">
<h2 id="copy-title" class="text-title">
{{ t('projectCreation.copyTitle') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('projectCreation.copyHelp') }}
</p>
</div>
<div class="gap-stack grid min-w-0">
<Label for="source_project_id">{{
t('projectCreation.sourceProject')
}}</Label>
<div class="grid gap-2">
<Label for="source_project_id"
>Source project</Label
>
<select
id="source_project_id"
v-model="form.source_project_id"
required
:class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_project_id
"
aria-describedby="source-project-error"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceProjects"
>
<option value="">
{{ t('projectCreation.selectProject') }}
Select a source project
</option>
<option
v-for="project in sourceProjects"
:key="project.id"
:value="String(project.id)"
v-for="sourceProject in sourceProjects"
:key="sourceProject.id"
:value="String(sourceProject.id)"
>
{{ project.name }} ({{ project.key }})
{{ sourceProject.name }} ({{
sourceProject.key
}})
</option>
</select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key,
and future ticket numbering.
</p>
<InputError
id="source-project-error"
:message="form.errors.source_project_id"
/>
</div>
<fieldset
class="min-w-0"
:disabled="form.processing"
aria-describedby="copy-help copy-areas-error"
>
<legend class="mb-2 font-semibold">
{{ t('projectCreation.copyOptions') }}
</legend>
<p
id="copy-help"
class="text-caption text-muted-foreground mb-3"
>
{{ t('projectCreation.copyDefaults') }}
</p>
<div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
<div class="grid gap-3">
<div>
<Label>Copy options</Label>
<p class="text-muted-foreground text-sm">
Choose which settings should be copied
into the new project.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="area.value"
:aria-labelledby="`copy-${area.value}-label`"
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
:value="copyAreaOption.value"
class="mt-1"
/>
<span class="gap-stack grid min-w-0">
<span
:id="`copy-${area.value}-label`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
)
}}</span
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
<span
:id="`copy-${area.value}-description`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
)
}}</span
>
</span>
</label>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</section>
<section
<p class="text-muted-foreground text-sm">
Operational ticket data is always left
behind so the new project starts clean.
</p>
<InputError :message="copyAreasError" />
</div>
</div>
<div
v-else-if="form.creation_mode === 'template'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-title"
class="space-y-4 rounded-xl border border-dashed p-4"
>
<h2 id="template-title" class="text-title">
{{ t('projectCreation.chooseTemplate') }}
</h2>
<div class="gap-stack grid min-w-0">
<Label for="source_template_id">{{
t('projectCreation.templateLabel')
}}</Label>
<div class="grid gap-2">
<Label for="source_template_id">Template</Label>
<select
id="source_template_id"
v-model="form.source_template_id"
required
:class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_template_id
"
aria-describedby="template-help source-template-error"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasSourceTemplates"
>
<option value="">
{{
t('projectCreation.selectTemplate')
}}
</option>
<option value="">Select a template</option>
<option
v-for="template in sourceTemplates"
:key="template.id"
:value="String(template.id)"
v-for="sourceTemplate in sourceTemplates"
:key="sourceTemplate.id"
:value="String(sourceTemplate.id)"
>
{{ template.name }} ({{
template.slug
{{ sourceTemplate.name }} ({{
sourceTemplate.slug
}})
</option>
</select>
<p
id="template-help"
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateHelp') }}
<p class="text-muted-foreground text-sm">
Templates apply their saved configuration
groups to the new project.
</p>
<InputError
id="source-template-error"
:message="form.errors.source_template_id"
/>
</div>
<div
v-if="selectedTemplate"
class="gap-stack grid"
aria-live="polite"
>
<h3 class="text-body font-semibold">
{{ t('projectCreation.includedAreas') }}
</h3>
<ul
v-if="selectedTemplate.copy_areas.length"
class="flex flex-wrap gap-2"
>
<li
v-for="area in selectedTemplate.copy_areas"
:key="area"
>
<Badge
variant="secondary"
class="whitespace-normal"
>{{
areaText(area, 'label')
}}</Badge
>
</li>
</ul>
<p
v-else
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateDefaults') }}
</p>
</div>
</section>
</div>
</div>
<aside
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="summary-title"
>
<h2 id="summary-title" class="text-title">
{{ t('projectCreation.summary') }}
</h2>
<dl class="gap-column grid min-w-0">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.name') }}
</dt>
<dd class="break-words font-semibold">
{{
form.name.trim() ||
t('projectCreation.untitled')
}}
</dd>
</div>
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.ticketPreview') }}
</dt>
<dd>
<Badge variant="outline">{{
ticketKey
}}</Badge>
</dd>
</div>
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.setup') }}
</dt>
<dd class="break-words">
{{ selectedMode?.title }}
</dd>
</div>
<div
v-if="form.creation_mode === 'duplicate'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words">
{{
selectedProject?.name ||
t('projectCreation.selectProject')
}}
</dd>
<dd class="text-caption text-muted-foreground">
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div
v-else-if="form.creation_mode === 'template'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.templateLabel') }}
</dt>
<dd class="break-words">
{{
selectedTemplate?.name ||
t('projectCreation.selectTemplate')
}}
</dd>
</div>
</dl>
<p
class="text-caption text-muted-foreground border-t pt-4"
<div
v-else-if="
!hasSourceProjects && !hasSourceTemplates
"
class="text-muted-foreground text-sm"
>
{{ t('projectCreation.cleanStart') }}
</p>
</aside>
</div>
No accessible source projects or saved templates are
available yet.
</div>
<footer
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
>
<Button
as-child
variant="outline"
:disabled="form.processing"
><Link :href="index()">{{
t('common.cancel')
}}</Link></Button
>
<Button
type="submit"
:loading="form.processing"
:aria-label="
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
"
:loading-label="t('projectCreation.creating')"
><FolderPlus
v-if="!form.processing"
aria-hidden="true"
/>{{
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
}}</Button
>
</footer>
</form>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create project
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
+70 -151
View File
@@ -1,184 +1,103 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
Columns3,
FolderGit2,
ListTodo,
Plus,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Folder, Plus } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { create, index, show } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
]);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('navigation.projects')" />
<Head title="Projects" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.projects') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.description') }}
</p>
</div>
<Heading
title="Projects"
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
class="self-start"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
>
<Link :href="create()">
<Plus class="size-4" />
New project
</Link>
</Button>
</header>
</div>
<FeedbackState
<div
v-if="status"
state="success"
:title="t('projects.updated')"
:description="status"
/>
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<FeedbackState
<div
v-if="projects.length === 0"
state="empty"
:title="t('projects.empty.title')"
:description="
$page.props.auth.permissions.projectCreate
? t('projects.empty.createDescription')
: t('projects.empty.accessDescription')
"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.empty.create')
}}</Link
>
</Button>
</FeedbackState>
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
<section
v-else
aria-labelledby="project-list-title"
class="gap-column grid"
>
<h2
id="project-list-title"
class="text-caption text-muted-foreground"
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
class="group block"
>
{{ t('projects.available', { count: projects.length }) }}
</h2>
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
<li
v-for="project in projects"
:key="project.id"
class="min-w-0"
<Card
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
>
<article
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
>
<Link
:href="show(project.id)"
:aria-label="
t('projects.open', { name: project.name })
"
class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
>
<span
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
>
<FolderGit2
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid min-w-0 flex-1">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h3
class="text-title group-hover:text-primary break-words"
>
{{ project.name }}
</h3>
<span
class="text-caption text-muted-foreground"
>{{ t('projects.overview') }}</span
>
<CardHeader class="gap-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>
Project workspace entry point
</CardDescription>
</div>
<ArrowUpRight
class="text-muted-foreground mt-1 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<div
class="gap-stack p-card grid grid-cols-2 border-t"
>
<Button as-child variant="secondary">
<Link
:href="boardIndex(project.id)"
:aria-label="
t('projects.openBoard', {
name: project.name,
})
"
>
<Columns3 aria-hidden="true" />{{
t('navigation.board')
}}
</Link>
</Button>
<Button as-child variant="ghost">
<Link
:href="backlogIndex(project.id)"
:aria-label="
t('projects.openBacklog', {
name: project.name,
})
"
>
<ListTodo aria-hidden="true" />{{
t('navigation.backlog')
}}
</Link>
</Button>
<Badge variant="outline" class="px-2.5 py-1">
{{ project.key }}
</Badge>
</div>
</article>
</li>
</ul>
</section>
</CardHeader>
<CardContent
class="text-muted-foreground flex items-center gap-2 text-sm"
>
<Folder class="size-4" />
Open board, backlog, and settings hub
</CardContent>
</Card>
</Link>
</div>
</div>
</AppLayout>
</template>
+167 -188
View File
@@ -1,227 +1,206 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowLeft,
ArrowRight,
Columns3,
Layers3,
ListTodo,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { projectSettingsNavigation } from '@/lib/navigation';
import { index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { access, index, show } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
]);
const projectAreas = computed(() => [
const breadcrumbs: BreadcrumbItem[] = [
{
title: t('navigation.board'),
description: t('projects.boardDescription'),
action: t('projects.boardAction'),
href: boardIndex(props.project.id),
icon: Columns3,
title: 'Projects',
href: index(),
},
{
title: t('navigation.backlog'),
description: t('projects.backlogDescription'),
action: t('projects.backlogAction'),
href: backlogIndex(props.project.id),
title: props.project.name,
href: show(props.project.id),
},
];
const projectAreas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo,
},
]);
const settingsItems = computed(() =>
projectSettingsNavigation(
{
id: props.project.id,
name: props.project.name,
key: props.project.key,
canManage: props.canManageAccess,
canCreateTickets: false,
},
t,
),
);
{
title: 'Board',
description:
'Track active work across the project-defined Kanban columns and update ticket status.',
icon: LayoutGrid,
},
{
title: 'Project settings',
description:
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
icon: Settings2,
},
{
title: 'Repositories',
description:
'Link project repositories so tickets can later discover related branches and pull requests.',
icon: FolderGit2,
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="project.name" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<header
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h1
class="text-headline-mobile md:text-headline break-words"
>
{{ project.name }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.hubDescription') }}
</p>
</div>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
class="self-start"
>
<Link :href="projectTemplateCreate(project.id)"
><Layers3 aria-hidden="true" />{{
t('projects.saveTemplate')
}}</Link
>
</Button>
</header>
<section
:aria-label="t('projects.workAreas')"
class="gap-column grid md:grid-cols-2"
>
<Link
v-for="area in projectAreas"
:key="area.href.url"
:href="area.href"
:aria-label="area.action"
class="gap-column bg-card hover:border-primary/50 hover:shadow-card-hover group flex min-w-0 flex-col rounded-md border p-5 transition-[border-color,box-shadow]"
>
<span
class="bg-accent text-primary flex size-11 items-center justify-center rounded-md"
>
<component
:is="area.icon"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid">
<h2 class="text-title">{{ area.title }}</h2>
<p class="text-muted-foreground">
{{ area.description }}
</p>
</div>
<span
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
>
{{ area.action
}}<ArrowRight class="size-4" aria-hidden="true" />
</span>
</Link>
</section>
<section
v-if="settingsItems.length"
aria-labelledby="project-settings-title"
class="gap-column grid"
>
<div class="gap-stack grid">
<h2 id="project-settings-title" class="text-title">
{{ t('navigation.projectSettings') }}
</h2>
<p class="text-muted-foreground">
{{ t('projects.settingsDescription') }}
</p>
</div>
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
<li
v-for="item in settingsItems"
:key="item.title"
class="min-w-0"
>
<Link
:href="item.href"
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
>
<component
:is="item.icon"
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span
class="min-w-0 flex-1 break-words font-medium"
>{{ item.title }}</span
>
<ArrowRight
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
</ul>
</section>
<section
v-if="templateState?.inheritsFromTemplate"
aria-labelledby="project-template-title"
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
>
<div class="gap-card flex items-start">
<Layers3
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
/>
<div class="gap-stack grid min-w-0">
<h2
id="project-template-title"
class="text-title break-words"
>
{{
t('projects.templateTitle', {
name: templateState.templateName,
})
}}
</h2>
<p class="text-muted-foreground">
{{ t('projects.templateDescription') }}
</p>
</div>
</div>
<div class="gap-stack flex flex-wrap">
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="index()">Back to projects</Link>
</Button>
</div>
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas
follow the template baseline; overridden areas were
customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
class="max-w-full whitespace-normal break-words"
>
{{ t('projects.inheritedArea', { name: area.label }) }}
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
variant="secondary"
class="max-w-full whitespace-normal break-words"
>
{{ t('projects.overriddenArea', { name: area.label }) }}
{{ area.label }} · overridden
</Badge>
</div>
</section>
</CardContent>
</Card>
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
<Button as-child>
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="access(project.id)">Manage access</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card
v-for="area in projectAreas"
:key="area.title"
class="h-full"
>
<CardHeader>
<div
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent class="text-muted-foreground text-sm">
Use the project shortcuts above to open this area.
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>
-9
View File
@@ -39,15 +39,6 @@ export type ProjectDuplicateCopyAreaOption = {
value: string;
};
export type AssignedTicketsProjectGroup = {
project: ProjectModel;
tickets: TicketModel[];
};
export type DashboardPageProps = {
assignedTicketsByProject: AssignedTicketsProjectGroup[];
};
export type ProjectsIndexPageProps = {
projects: ProjectModel[];
status?: string | null;
-12
View File
@@ -1,12 +0,0 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type TicketPresentationProps = {
projectId: number;
showDescription?: boolean;
showStatus?: boolean;
ticket: TicketModel;
};
export type TicketPresentationEmits = {
open: [ticketId: number];
};
+1 -2
View File
@@ -1,6 +1,5 @@
<?php
use App\Http\Controllers\DashboardController;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -18,7 +17,7 @@ Route::get('api/docs/openapi.json', function (): Response {
})->name('api.docs.openapi');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
require __DIR__.'/web/admin.php';
require __DIR__.'/web/project-templates.php';
+4 -95
View File
@@ -2,117 +2,26 @@
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_the_login_page(): void
public function test_guests_are_redirected_to_the_login_page()
{
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_users_see_an_empty_dashboard_without_assigned_tickets(): void
public function test_authenticated_users_can_visit_the_dashboard()
{
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('assignedTicketsByProject', 0));
}
public function test_dashboard_only_contains_the_users_tickets_from_visible_projects(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$directProject = Project::factory()->create([
'key' => 'WEB',
'name' => 'Web platform',
]);
$teamProject = Project::factory()->create([
'key' => 'OPS',
'name' => 'Operations',
]);
$hiddenProject = Project::factory()->create([
'key' => 'SEC',
'name' => 'Private security',
]);
$team = Team::factory()->create();
$team->users()->attach($user);
$this->syncProjectPermissions($directProject, $user, ['view']);
$this->syncProjectPermissions($teamProject, $team, ['view']);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $directProject->id,
'sequence_number' => 1,
'ticket_key' => 'WEB-1',
'title' => 'Older assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $directProject->id,
'sequence_number' => 2,
'ticket_key' => 'WEB-2',
'title' => 'Newest assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $teamProject->id,
'sequence_number' => 1,
'ticket_key' => 'OPS-1',
'title' => 'Team-visible assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $otherUser->id,
'project_id' => $directProject->id,
'sequence_number' => 3,
'ticket_key' => 'WEB-3',
'title' => 'Another users ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $hiddenProject->id,
'sequence_number' => 1,
'ticket_key' => 'SEC-1',
'title' => 'Hidden project ticket',
]);
$this->actingAs($user)
->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('assignedTicketsByProject', 2)
->where('assignedTicketsByProject.0.project.id', $teamProject->id)
->has('assignedTicketsByProject.0.tickets', 1)
->where('assignedTicketsByProject.0.tickets.0.ticket_key', 'OPS-1')
->where('assignedTicketsByProject.1.project.id', $directProject->id)
->has('assignedTicketsByProject.1.tickets', 2)
->where('assignedTicketsByProject.1.tickets.0.ticket_key', 'WEB-2')
->where('assignedTicketsByProject.1.tickets.1.ticket_key', 'WEB-1'));
}
public function test_dashboard_presentation_uses_real_data_and_handles_empty_state(): void
{
$process = new Process(['node', 'tests/Fixtures/dashboard.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Dashboard behavior checks passed.', $process->getOutput());
$response = $this->get(route('dashboard'));
$response->assertOk();
}
}
@@ -23,9 +23,6 @@ class ProjectBacklogTest extends TestCase
public function test_users_with_view_permission_can_view_a_projects_backlog(): void
{
$user = User::factory()->create();
$assignee = User::factory()->create([
'name' => 'Taylor Reed',
]);
$project = Project::factory()->create([
'key' => 'AUTH',
]);
@@ -42,7 +39,6 @@ class ProjectBacklogTest extends TestCase
->firstOrFail();
$ticket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'project_id' => $project->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
@@ -63,10 +59,6 @@ class ProjectBacklogTest extends TestCase
->where('canEditTickets', false)
->where('project.id', $project->id)
->where('tickets.0.ticket_key', $ticket->ticket_key)
->where('tickets.0.assignee.id', $assignee->id)
->where('tickets.0.assignee.name', $assignee->name)
->where('tickets.0.ticket_priority.name', $priority->name)
->where('tickets.0.ticket_status.name', $backlogStatus->name)
->where('canCreateTickets', false),
);
}
@@ -24,9 +24,6 @@ class ProjectBoardControllerTest extends TestCase
public function test_users_with_view_permission_can_view_a_projects_board(): void
{
$user = User::factory()->create();
$assignee = User::factory()->create([
'name' => 'Taylor Reed',
]);
$project = Project::factory()->create([
'key' => 'CORE',
]);
@@ -61,7 +58,6 @@ class ProjectBoardControllerTest extends TestCase
]);
$boardTicket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
@@ -85,10 +81,6 @@ class ProjectBoardControllerTest extends TestCase
->has('boardColumns.1.tickets', 1)
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
->where('boardColumns.1.tickets.0.assignee.id', $assignee->id)
->where('boardColumns.1.tickets.0.assignee.name', $assignee->name)
->where('boardColumns.1.tickets.0.ticket_priority.name', $priority->name)
->where('boardColumns.1.tickets.0.ticket_status.name', $doingStatus->name)
->where('canCreateTickets', false)
->where('canEditTickets', false),
);
@@ -212,19 +204,6 @@ class ProjectBoardControllerTest extends TestCase
'entry_type' => 'status_changed',
'summary' => 'Moved from Todo to Done.',
]);
$this->assertSame(1, $ticket->worklogEntries()->count());
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
'redirect_to' => 'board',
'ticket_status_id' => $doneStatus->id,
])
->assertRedirect(route('projects.board.index', $project))
->assertSessionHasNoErrors();
$this->assertSame($doneStatus->id, $ticket->fresh()->ticket_status_id);
$this->assertSame(1, $ticket->worklogEntries()->count());
}
/**
@@ -1,19 +0,0 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectBoardPresentationTest extends TestCase
{
public function test_board_renders_configured_status_groups_counts_and_permission_aware_actions(): void
{
$process = new Process(['node', 'tests/Fixtures/board.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Board behavior checks passed.', $process->getOutput());
}
}
@@ -1,50 +0,0 @@
<?php
namespace Tests\Feature\Projects;
use App\Models\ProjectTemplate;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectCreationTest extends TestCase
{
use RefreshDatabase;
#[DataProvider('missingSources')]
public function test_creation_requires_the_selected_source(string $mode, string $field): void
{
$user = User::factory()->admin()->create();
$this->actingAs($user)->from(route('projects.create'))
->post(route('projects.store'), ['creation_mode' => $mode, 'name' => 'New project', 'key' => 'NEW'])
->assertRedirect(route('projects.create'))->assertSessionHasErrors($field);
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
}
/** @return array<string, array{string, string}> */
public static function missingSources(): array
{
return ['duplicate' => ['duplicate', 'source_project_id'], 'template' => ['template', 'source_template_id']];
}
public function test_an_unpublished_template_cannot_be_used_to_create_a_project(): void
{
$user = User::factory()->admin()->create();
$template = ProjectTemplate::factory()->create(['is_published' => false]);
$this->actingAs($user)->post(route('projects.store'), [
'creation_mode' => 'template', 'source_template_id' => $template->id, 'name' => 'New project', 'key' => 'NEW',
])->assertSessionHasErrors('source_template_id');
$this->assertDatabaseMissing('projects', ['key' => 'NEW']);
}
public function test_creation_page_rendering_and_localization(): void
{
$process = new Process(['node', 'tests/Fixtures/project-creation.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project creation rendering checks passed.', $process->getOutput());
}
}
@@ -1,84 +0,0 @@
<?php
namespace Tests\Feature\Projects;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectOverviewTest extends TestCase
{
use RefreshDatabase;
public function test_projects_without_access_do_not_appear_in_the_empty_overview(): void
{
$user = User::factory()->create();
Project::factory()->create(['name' => 'Private workspace']);
$this->actingAs($user)->get(route('projects.index'))
->assertOk()->assertInertia(fn (Assert $page) => $page
->component('projects/Index')
->has('projects', 0)
->where('auth.permissions.projectCreate', false));
$this->syncGlobalPermissions($user, ['project:create']);
$this->get(route('projects.index'))
->assertOk()->assertInertia(fn (Assert $page) => $page
->has('projects', 0)
->where('auth.permissions.projectCreate', true));
}
#[DataProvider('projectRoles')]
public function test_project_entry_actions_follow_effective_permissions(string $role, bool $teamAccess, bool $canManage): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$assignee = $user;
if ($teamAccess) {
$assignee = Team::factory()->create();
$assignee->users()->attach($user);
}
$this->syncProjectPermissions($project, $assignee, [$role]);
$this->actingAs($user)->get(route('projects.show', $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('project.id', $project->id)
->where('canManageAccess', $canManage)
->where('auth.permissions.projectCreate', false));
foreach (['projects.board.index', 'projects.backlog.index'] as $route) {
$this->get(route($route, $project))->assertOk();
}
foreach (['projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) {
$this->get(route($route, $project))->assertStatus($canManage ? 200 : 403);
}
}
/** @return array<string, array{string, bool, bool}> */
public static function projectRoles(): array
{
return [
'reader' => ['read', false, false],
'writer' => ['write', false, false],
'project admin' => ['admin', false, true],
'team reader' => ['read', true, false],
'team admin' => ['admin', true, true],
];
}
public function test_project_overview_rendering_and_actions(): void
{
$process = new Process(['node', 'tests/Fixtures/project-overview.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project overview behavior checks passed.', $process->getOutput());
}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketPresentationsTest extends TestCase
{
public function test_ticket_cards_and_list_items_share_accessible_data_driven_behavior(): void
{
$process = new Process(['node', 'tests/Fixtures/ticket-presentations.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Ticket presentation behavior checks passed.', $process->getOutput());
}
}
@@ -1,30 +0,0 @@
<script setup>
import { TicketCard, TicketListItem } from '@/components/tickets';
defineProps({
fullTicket: Object,
optionalTicket: Object,
});
</script>
<template>
<main class="mx-auto grid max-w-5xl gap-8 p-4 sm:p-8">
<section class="grid gap-4" aria-labelledby="cards-title">
<h1 id="cards-title" class="text-headline-mobile md:text-headline">
Ticket cards
</h1>
<div class="grid gap-4 sm:grid-cols-2">
<TicketCard :project-id="7" :ticket="fullTicket" />
<TicketCard :project-id="7" :ticket="optionalTicket" />
</div>
</section>
<section class="grid gap-4" aria-labelledby="rows-title">
<h2 id="rows-title" class="text-title">Ticket list items</h2>
<ul class="grid gap-4">
<TicketListItem :project-id="7" :ticket="fullTicket" />
<TicketListItem :project-id="7" :ticket="optionalTicket" />
</ul>
</section>
</main>
</template>
-239
View File
@@ -1,239 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const base = 'http://127.0.0.1:4181/projects/7/board';
const content = () => page.locator('#main-content');
const visibleColumns = () =>
content().locator('[id^="board-column-title-"]:visible');
const visit = async (query = '') => {
await page.goto(base + query);
await page.getByRole('heading', { level: 1 }).waitFor();
await page.evaluate(() => document.fonts.ready);
};
const tabTo = async (locator) => {
await content().focus();
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (
await locator.evaluate(
(element) => element === document.activeElement,
)
) {
assert.equal(
await locator.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Board control is unreachable by keyboard');
};
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
for (const [query, state] of [
['', 'filled'],
['?empty', 'empty'],
['?one&noTickets', 'one-empty'],
]) {
await visit(query);
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${state} overflow at ${width}/${theme}`,
);
assert.equal(
await visibleColumns().count(),
state === 'empty'
? 0
: width < 768 || state === 'one-empty'
? 1
: 8,
);
if (width < 768 && state !== 'empty') {
assert.ok(
(await page.getByLabel('Choose a column').boundingBox())
.height >= 44,
);
}
if (state === 'filled' && width >= 768) {
const region = content().getByRole('region', {
name: 'Workflow columns',
exact: true,
});
assert.equal(
await region.evaluate(
(el) => el.scrollWidth > el.clientWidth,
),
true,
);
await region.focus();
await page.keyboard.press('End');
await content()
.locator('#board-column-8')
.scrollIntoViewIfNeeded();
assert.ok(await region.evaluate((el) => el.scrollLeft > 0));
await region.evaluate((el) => {
el.scrollLeft = 0;
});
}
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: `/private/tmp/kan27-board-${state}-${width}-${theme}.png`,
fullPage: true,
});
}
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await visit();
const chooseColumn = page.getByLabel('Choose a column');
await tabTo(chooseColumn);
// Native popup selection is handled by macOS outside the browser DOM.
await chooseColumn.selectOption('2');
assert.equal(await chooseColumn.inputValue(), '2');
assert.equal(await visibleColumns().innerText(), 'In development');
await chooseColumn.selectOption('8');
assert.equal(await visibleColumns().innerText(), 'Column without statuses');
await content()
.getByText('No statuses configured for this column.')
.waitFor();
await chooseColumn.selectOption('1');
await content()
.getByRole('heading', { name: 'Ready · Planned', exact: true })
.waitFor();
await content()
.getByRole('heading', { name: 'Ready · Active', exact: true })
.waitFor();
const terminalSelect = page.getByLabel('Move ENG-43', { exact: true });
assert.equal(await terminalSelect.isDisabled(), true);
const moveSelect = page.getByLabel('Move ENG-42', { exact: true });
assert.deepEqual(
await moveSelect
.locator('option')
.evaluateAll((options) => options.map((option) => option.value)),
['20', '11', '99'],
);
await tabTo(moveSelect);
await moveSelect.selectOption('20');
await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Update status');
await page.keyboard.press('Enter');
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(await chooseColumn.inputValue(), '1');
assert.match(
await chooseColumn.locator('option').first().innerText(),
/\(1\)/,
);
await chooseColumn.selectOption('2');
await content()
.getByRole('link', {
name: 'Open ENG-42: Ship accessible board navigation',
exact: true,
})
.waitFor();
await visit();
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('99');
await content()
.locator('article')
.filter({ hasText: 'ENG-42' })
.getByRole('button', { name: 'Update status' })
.click();
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(
await content()
.getByRole('link', {
name: 'Open ENG-42: Ship accessible board navigation',
exact: true,
})
.count(),
0,
);
assert.match(
await chooseColumn.locator('option').first().innerText(),
/\(1\)/,
);
await visit('?reject');
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('20');
await content()
.locator('article')
.filter({ hasText: 'ENG-42' })
.getByRole('button', { name: 'Update status' })
.click();
await page
.getByText('This transition is no longer allowed.', { exact: true })
.waitFor();
assert.equal(
await page
.getByLabel('Move ENG-42', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.equal(
await content()
.getByText('This transition is no longer allowed.', { exact: true })
.count(),
1,
);
assert.equal(
await page
.getByLabel('Move ENG-43', { exact: true })
.getAttribute('aria-invalid'),
null,
);
await visit('?readonly');
assert.equal(await content().locator('form').count(), 0);
const ticketLink = content().getByRole('link', {
name: 'Open ENG-42: Ship accessible board navigation',
exact: true,
});
await tabTo(ticketLink);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/tickets/42');
await page
.getByRole('heading', { name: 'Destination reached', exact: true })
.waitFor();
await visit('?empty');
await tabTo(
content().getByRole('link', { name: 'Open backlog', exact: true }),
);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/backlog');
assert.deepEqual(errors, []);
console.log(
'Board browser checks passed: responsive columns, keyboard, status changes and validation, permissions, empty states and both themes.',
);
} finally {
await browser.close();
}
-288
View File
@@ -1,288 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({
viewport: { width: 1440, height: 1200 },
hasTouch: true,
});
page.setDefaultTimeout(10000);
const failures = [];
const requests = [];
page.on('pageerror', (error) => failures.push(error.message));
page.on('request', (request) => {
if (['POST', 'PATCH'].includes(request.method())) {
requests.push(request);
}
});
const base = 'http://127.0.0.1:4181/projects/7/board';
const handle = () =>
page.getByRole('button', {
name: 'Drag ENG-42 to change status',
exact: true,
});
const card = () => page.locator('article').filter({ hasText: 'ENG-42' });
const visit = async (query = '') => {
await page.goto(base + query);
await page.getByRole('heading', { level: 1 }).waitFor();
requests.length = 0;
};
const center = async (locator) => {
const box = await locator.boundingBox();
assert.ok(box);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
};
const begin = async () => {
await handle().scrollIntoViewIfNeeded();
const from = await center(handle());
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + 12, from.y, { steps: 3 });
};
const moveToColumn = async (id) => {
const to = await center(page.locator(`#board-column-title-${id}`));
await page.mouse.move(to.x, to.y, { steps: 10 });
};
const dragToColumn = async (id) => {
await begin();
await moveToColumn(id);
await page.mouse.up();
};
const originalCard = async () => {
assert.equal(
await page
.locator('#board-column-1 article')
.filter({ hasText: 'ENG-42' })
.count(),
1,
);
};
try {
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await visit();
assert.equal(
await page
.getByRole('button', { name: 'Drag ENG-43 to change status' })
.isDisabled(),
true,
);
await begin();
assert.equal(
await page
.locator('#board-column-2')
.evaluate((el) => el.classList.contains('border-primary')),
true,
);
assert.equal(
await page
.locator('#board-column-3')
.evaluate((el) => el.classList.contains('opacity-40')),
true,
);
await moveToColumn(3);
await page.mouse.up();
await originalCard();
assert.equal(requests.length, 0);
await begin();
await moveToColumn(2);
await page.keyboard.press('Escape');
await page.mouse.up();
assert.equal(requests.length, 0);
await originalCard();
// Pointer capture cancellation must never become a drop.
await begin();
await handle().dispatchEvent('pointercancel', { pointerId: 1 });
await page.mouse.up();
assert.equal(requests.length, 0);
await visit('?multiple');
await dragToColumn(2);
const dialog = page.getByRole('dialog');
await dialog.waitFor();
assert.equal(requests.length, 0);
assert.deepEqual(
await dialog
.locator('option')
.evaluateAll((options) =>
options.map((option) => option.value),
),
['20', '21'],
);
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert.equal(
await handle().evaluate((el) => el === document.activeElement),
true,
);
assert.equal(requests.length, 0);
await originalCard();
await dragToColumn(2);
const select = dialog.getByLabel('Destination status');
await select.focus();
await select.selectOption('21');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Update status');
await page.keyboard.press('Enter');
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(requests.length, 1);
assert.deepEqual(requests[0].postDataJSON(), {
ticket_status_id: 21,
redirect_to: 'board',
});
assert.equal(
await page
.locator('#board-column-2 article')
.filter({ hasText: 'ENG-42' })
.count(),
1,
);
await visit('?reject');
await dragToColumn(2);
await page
.getByText('This transition is no longer allowed.', { exact: true })
.waitFor();
await originalCard();
assert.equal(requests.length, 1);
await visit('?slow');
await dragToColumn(2);
await page.locator('[data-form-state="loading"]').waitFor();
assert.equal(await handle().isDisabled(), true);
assert.equal(
await page.getByLabel('Move ENG-42', { exact: true }).isDisabled(),
true,
);
// Attempt a competing form submit even though its controls are disabled.
await card()
.locator('form')
.evaluate((form) => form.requestSubmit());
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(requests.length, 1);
await visit('?slow');
await card()
.getByRole('button', { name: 'Update status', exact: true })
.click();
assert.equal(await handle().isDisabled(), true);
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(requests.length, 1);
await visit();
await page.route('**/tickets/42/status', (route) =>
route.abort('failed'),
);
await dragToColumn(2);
await page.locator('[data-form-state="network"]').waitFor();
await originalCard();
assert.equal(await handle().isEnabled(), true);
await page.unroute('**/tickets/42/status');
await page
.getByRole('button', { name: 'Try again', exact: true })
.click();
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(requests.length, 2);
await visit();
await page.route('**/tickets/42/status', (route) =>
route.fulfill({ status: 500, body: 'Unavailable' }),
);
await dragToColumn(2);
await page.locator('[data-form-state="server"]').waitFor();
await originalCard();
await page.unroute('**/tickets/42/status');
// Normal touch gestures still scroll; only the explicit handle captures a drag.
await page.setViewportSize({ width: 390, height: 1000 });
await visit('?multiple');
await handle().scrollIntoViewIfNeeded();
assert.equal(
await handle().evaluate((el) => getComputedStyle(el).touchAction),
'none',
);
assert.notEqual(
await card().evaluate((el) => getComputedStyle(el).touchAction),
'none',
);
const cdp = await page.context().newCDPSession(page);
const from = await center(handle());
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ ...from, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: from.x + 16, y: from.y, id: 1 }],
});
const tray = page.locator('[data-board-mobile-targets]');
await tray.waitFor();
const to = await center(tray.locator('[data-board-drop-column="2"]'));
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ ...to, id: 1 }],
});
await page.screenshot({
path: `/private/tmp/kan28-mobile-drag-${theme}.png`,
fullPage: true,
});
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
true,
);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await page.getByRole('dialog').waitFor();
await page.getByLabel('Destination status').selectOption('20');
await page
.getByRole('dialog')
.getByRole('button', { name: 'Update status' })
.click();
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Choose a column').inputValue(),
'2',
);
assert.equal(requests.length, 1);
await cdp.detach();
await page.screenshot({
path: `/private/tmp/kan28-mobile-result-${theme}.png`,
fullPage: true,
});
await visit('?readonly');
assert.equal(
await page.getByRole('button', { name: /Drag ENG/ }).count(),
0,
);
await page.setViewportSize({ width: 1440, height: 1200 });
}
assert.deepEqual(failures, []);
console.log(
'Board drag checks passed: mouse, touch, workflow restrictions, selection, cancellation, focus, request guards and recovery in both themes.',
);
} finally {
await browser.close();
}
-19
View File
@@ -1,19 +0,0 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Board from '../../resources/js/pages/projects/Board.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Board'
? Board
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
-287
View File
@@ -1,287 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/board-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4181, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const project = {
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
};
const columnNames = [
'Ready',
'In development',
'Review',
'Testing',
'Release',
'Done',
'Archived',
'Column without statuses',
];
const columns = columnNames.map((name, index) => ({
id: index + 1,
name,
position: index + 1,
project_id: 7,
tickets: [],
ticket_statuses: [],
}));
const statuses = columns.slice(0, -1).flatMap((column) =>
['Planned', 'Active'].map((name, index) => ({
id: column.id * 10 + index,
name: `${column.name} · ${name}`,
position: index + 1,
project_id: 7,
board_column_id: column.id,
board_column: { ...column },
})),
);
const backlog = {
id: 99,
name: 'Backlog',
board_column_id: null,
board_column: null,
};
for (const column of columns) {
column.ticket_statuses = statuses.filter(
(status) => status.board_column_id === column.id,
);
}
function ticket(id, status, title) {
return {
id,
title,
ticket_key: `ENG-${id}`,
project_id: 7,
sequence_number: id,
description:
'A real ticket card in the isolated board behavior fixture.',
ticket_status_id: status.id,
ticket_status: {
...status,
allowed_target_statuses:
id === 42 ? [status, statuses[2], statuses[1], backlog] : [],
},
ticket_priority: { id: 1, name: 'Urgent custom priority', position: 1 },
ticket_type: { id: 1, name: 'Development', is_parent: false },
assignee: null,
assignee_user_id: null,
is_blocked: id === 43,
blocked_comment: id === 43 ? 'Awaiting the external review.' : null,
};
}
function makePage(url) {
const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams;
const isBoard = parsed.pathname === '/projects/7/board';
const boardColumns = structuredClone(
params.has('empty')
? []
: params.has('one')
? columns.slice(0, 1)
: columns,
);
if (!params.has('noTickets')) {
const target =
[...statuses, backlog].find(
(status) => status.id === Number(params.get('target')),
) ?? statuses[0];
const tickets = [
ticket(42, target, 'Ship accessible board navigation'),
ticket(
43,
statuses[1],
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
),
];
if (params.has('multiple')) {
tickets[0].ticket_status.allowed_target_statuses.push(statuses[3]);
}
for (const column of boardColumns) {
column.tickets = tickets.filter(
(item) => item.ticket_status.board_column_id === column.id,
);
}
}
return {
component: isBoard ? 'Board' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: params.has('rejected')
? {
'board-ticket-42': {
ticket_status_id:
'This transition is no longer allowed.',
},
}
: {},
sidebarOpen: true,
auth: {
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: {
project: {
...project,
canManage: false,
canCreateTickets: !params.has('readonly'),
},
},
project,
boardColumns,
canCreateTickets: !params.has('readonly'),
canEditTickets: !params.has('readonly'),
status: params.has('target') ? 'ENG-42 moved successfully.' : null,
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
if (request.method === 'POST' || request.method === 'PATCH') {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = JSON.parse(body);
assert.equal(
request.url,
request.method === 'POST'
? '/projects/7/tickets/42/status?_method=PATCH'
: '/projects/7/tickets/42/status',
);
assert.deepEqual(Object.keys(data).sort(), [
'redirect_to',
'ticket_status_id',
]);
assert.equal(data.redirect_to, 'board');
assert.ok([11, 20, 21, 99].includes(Number(data.ticket_status_id)));
const previous = new URL(request.headers.referer);
const reject = previous.searchParams.has('reject');
if (previous.searchParams.has('slow')) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
response.writeHead(303, {
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
});
response.end();
return;
}
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/board-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log(
'Board fixture ready at http://127.0.0.1:4181/projects/7/board',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Board } = await server.ssrLoadModule(
'/resources/js/pages/projects/Board.vue',
);
const render = async (query = '') => {
const result = await createInertiaApp({
page: makePage(`/projects/7/board${query}`),
resolve: () => Board,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return result.body.split('id="main-content"')[1];
};
const board = await render();
assert.equal((board.match(/id="board-column-title-/g) ?? []).length, 8);
assert.equal((board.match(/id="board-status-/g) ?? []).length, 14);
assert.match(board, /Ready · Planned/);
assert.match(board, /Ready · Active/);
assert.match(board, /No statuses configured for this column/);
assert.match(board, /No tickets in this column/);
assert.match(board, /No tickets in this status/);
assert.match(board, /2 tickets/);
assert.match(board, /Urgent custom priority/);
assert.match(board, /href="\/projects\/7\/tickets\/42"/);
assert.match(
board,
/action="\/projects\/7\/tickets\/42\/status\?_method=PATCH"/,
);
assert.match(
board,
/href="\/projects\/7\/tickets\/create\?source=board"/,
);
assert.match(board, /href="\/projects\/7\/backlog"/);
assert.match(board, /No allowed moves/);
assert.equal((board.match(/<article/g) ?? []).length, 2);
const readonly = await render('?readonly');
assert.doesNotMatch(readonly, /<form|Move ENG-|New ticket/);
const empty = await render('?empty');
assert.match(empty, /No workflow columns yet/);
assert.doesNotMatch(empty, /id="board-column"|<article/);
const one = await render('?one&noTickets');
assert.equal((one.match(/id="board-column-title-/g) ?? []).length, 1);
assert.match(one, /0 tickets/);
assert.doesNotMatch(one, /<article/);
console.log('Board behavior checks passed.');
} finally {
await server.close();
}
}
-108
View File
@@ -1,108 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4180';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const content = () => page.locator('#main-content');
const focusWithTab = async (link) => {
await content().focus();
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (
await link.evaluate((element) => element === document.activeElement)
) {
assert.equal(
await link.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Dashboard action was not reachable by keyboard');
};
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
for (const [path, state] of [
['/dashboard', 'filled'],
['/dashboard?empty', 'empty'],
]) {
await page.goto(base + path);
await page
.getByRole('heading', {
name: 'Welcome back, Alex Morgan',
exact: true,
level: 1,
})
.waitFor();
await page.evaluate(() => document.fonts.ready);
assert.equal(
await page
.locator('html')
.evaluate((element) =>
element.classList.contains('dark'),
),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${state} dashboard overflows at ${width} in ${theme}`,
);
await page.screenshot({
path: `/private/tmp/kan25-dashboard-${state}-${width}-${theme}.png`,
fullPage: true,
});
}
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await page.goto(`${base}/dashboard`);
const ticket = content().getByRole('link', {
name: 'Open ENG-42: Ship the dashboard with real assigned work',
exact: true,
});
await focusWithTab(ticket);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/tickets/42');
await page
.getByRole('heading', { name: 'Destination reached', exact: true })
.waitFor();
await page.goto(`${base}/dashboard?empty`);
const projects = content().getByRole('link', {
name: 'Browse projects',
exact: true,
});
await focusWithTab(projects);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects');
assert.deepEqual(errors, []);
console.log(
'Dashboard browser checks passed: keyboard, empty and filled states, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
-19
View File
@@ -1,19 +0,0 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Dashboard from '../../resources/js/pages/Dashboard.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
({ Dashboard })[name] ?? {
render: () => h('h1', 'Destination reached'),
},
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
-248
View File
@@ -1,248 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/dashboard-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4180, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 8,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
avatar_color: '#22c55e',
avatar_is_generated: true,
email_verified_at: '2026-09-11T00:00:00Z',
must_change_password_on_next_login: false,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const project = {
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
show_author: true,
show_story_points: true,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const secondProject = {
...project,
id: 9,
key: 'OPS',
name: 'Operations',
};
const ticketDefaults = {
acceptance_criteria: [],
assignee: user,
assignee_user_id: user.id,
author: null,
author_user_id: null,
blocked_comment: null,
description: 'Real work loaded from the server.',
is_blocked: false,
parent_ticket_id: null,
story_points: 3,
ticket_priority: {
id: 1,
name: 'Medium',
position: 3,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_priority_id: 1,
ticket_status: {
id: 3,
board_column_id: 2,
name: 'In Progress',
position: 1,
project_id: 7,
slug: 'in-progress',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
board_column: {
id: 2,
name: 'In Progress',
position: 2,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
},
ticket_status_id: 3,
ticket_type: {
id: 1,
is_parent: false,
name: 'Dev task',
position: 1,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_type_id: 1,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const tickets = [
{
...ticketDefaults,
id: 42,
project_id: project.id,
sequence_number: 42,
ticket_key: 'ENG-42',
title: 'Ship the dashboard with real assigned work',
},
{
...ticketDefaults,
id: 41,
project_id: project.id,
sequence_number: 41,
ticket_key: 'ENG-41',
title: 'Verify keyboard navigation',
},
];
const blockedTicket = {
...ticketDefaults,
id: 12,
blocked_comment: 'Waiting for review.',
is_blocked: true,
project_id: secondProject.id,
sequence_number: 12,
ticket_key: 'OPS-12',
title: 'Resolve the deployment blocker',
};
const assignedTicketsByProject = [
{ project: secondProject, tickets: [blockedTicket] },
{ project, tickets },
];
function makePage(url) {
const parsedUrl = new URL(url, 'http://localhost');
const isDashboard = parsedUrl.pathname === '/dashboard';
return {
component: isDashboard ? 'Dashboard' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
auth: {
user,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: { project: null },
...(isDashboard
? {
assignedTicketsByProject: parsedUrl.searchParams.has(
'empty',
)
? []
: assignedTicketsByProject,
}
: {}),
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/dashboard-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Dashboard fixture ready at http://127.0.0.1:4180/dashboard');
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Dashboard } = await server.ssrLoadModule(
'/resources/js/pages/Dashboard.vue',
);
const render = async (url) => {
const response = await createInertiaApp({
page: makePage(url),
resolve: () => Dashboard,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return response.body.split('<main')[1].split('</main>')[0];
};
const dashboard = await render('/dashboard');
assert.match(dashboard, /Welcome back, Alex Morgan/);
assert.match(dashboard, /Assigned tickets/);
assert.match(dashboard, /Projects with assigned work/);
assert.match(dashboard, /Blocked tickets/);
assert.match(dashboard, /Ship the dashboard with real assigned work/);
assert.match(dashboard, /Resolve the deployment blocker/);
assert.match(dashboard, /href="\/projects\/7\/tickets\/42"/);
assert.match(dashboard, /href="\/projects\/9"/);
assert.doesNotMatch(
dashboard,
/API-integration|Finished this week|Recent activity|PlaceholderPattern/,
);
const empty = await render('/dashboard?empty');
assert.match(empty, /No tickets assigned to you/);
assert.match(empty, /href="\/projects"/);
assert.doesNotMatch(empty, /ENG-42|OPS-12|<article/);
console.log('Dashboard behavior checks passed.');
} finally {
await server.close();
}
}
-334
View File
@@ -1,334 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4179';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('dialog', (dialog) => dialog.accept());
const setResult = (value) =>
context.addCookies([{ name: 'creation_test_result', value, url: base }]);
const content = () => page.locator('#main-content');
const state = (value) =>
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
const choose = async (value) => {
await page.locator(`input[name="creation_mode"][value="${value}"]`).focus();
await page.keyboard.press('Space');
};
const fillDetails = async () => {
await page
.getByLabel('Project name', { exact: true })
.fill('Frontend workspace');
await page.getByLabel('Project key', { exact: true }).fill('web');
};
const submit = () =>
page.getByRole('button', { name: 'Create project', exact: true }).click();
const focusWithTab = async (target) => {
await content().focus();
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (
await target.evaluate(
(element) => element === document.activeElement,
)
) {
assert.equal(
await target.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Form control was not reachable by keyboard');
};
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await page.goto(
`${base}/projects/create?viewport=${width}&theme=${theme}`,
);
await page
.getByRole('heading', { name: 'Create a project' })
.waitFor();
for (const mode of ['scratch', 'duplicate', 'template']) {
await choose(mode);
await page.evaluate(() => document.fonts.ready);
assert.equal(
await page
.locator('html')
.evaluate((element) =>
element.classList.contains('dark'),
),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${mode} overflows at ${width} in ${theme}`,
);
await page.screenshot({
path: `/private/tmp/kan24-${mode}-${width}-${theme}.png`,
fullPage: true,
});
}
await choose('scratch');
const scratch = page.getByRole('radio', {
name: 'Start from scratch',
exact: true,
});
await focusWithTab(scratch);
await page.keyboard.press('ArrowRight');
assert.equal(
await page
.getByRole('radio', {
name: 'Duplicate a project',
exact: true,
})
.isChecked(),
true,
);
const board = page.getByRole('checkbox', {
name: 'Board columns',
exact: true,
});
await focusWithTab(board);
await page.keyboard.press('Space');
assert.equal(await board.isChecked(), true);
const source = page.getByLabel('Source project', { exact: true });
await focusWithTab(source);
await page.keyboard.press('o');
await page.keyboard.press('Tab');
await page.waitForFunction(
() =>
document.querySelector('#source_project_id').value === '8',
);
assert.equal(await source.inputValue(), '8');
}
}
await page.goto(`${base}/projects/create?empty`);
assert.equal(
await page
.getByRole('radio', { name: 'Start from scratch', exact: true })
.isChecked(),
true,
);
assert.equal(
await page
.getByRole('radio', { name: 'Duplicate a project', exact: true })
.isDisabled(),
true,
);
assert.equal(
await page
.getByRole('radio', { name: 'Use a template', exact: true })
.isDisabled(),
true,
);
await fillDetails();
const scratchRequest = page.waitForRequest(
(request) => request.method() === 'POST',
);
await submit();
assert.deepEqual((await scratchRequest).postDataJSON(), {
name: 'Frontend workspace',
key: 'web',
creation_mode: 'scratch',
source_project_id: '',
source_template_id: '',
copy_areas: [],
});
await page.waitForURL('**/projects/99');
await page.goto(`${base}/projects/create?template=4`);
assert.equal(
await page.getByLabel('Template', { exact: true }).inputValue(),
'4',
);
await page
.getByText('This template uses the default project setup.', {
exact: true,
})
.waitFor();
await fillDetails();
const templateRequest = page.waitForRequest(
(request) => request.method() === 'POST',
);
await submit();
const templateData = (await templateRequest).postDataJSON();
assert.equal(templateData.creation_mode, 'template');
assert.equal(templateData.source_template_id, '4');
assert.equal(templateData.source_project_id, '');
assert.deepEqual(templateData.copy_areas, []);
await page.waitForURL('**/projects/99');
await page.goto(`${base}/projects/create`);
await choose('duplicate');
await fillDetails();
await page.getByLabel('Source project', { exact: true }).selectOption('8');
await page
.getByRole('checkbox', { name: 'Board columns', exact: true })
.check();
await page
.getByRole('checkbox', { name: 'Custom ticket fields', exact: true })
.check();
await page.getByText('2 areas selected', { exact: true }).waitFor();
await page.getByText('WEB-1', { exact: true }).waitFor();
await choose('template');
await choose('scratch');
await choose('duplicate');
assert.equal(
await page.getByLabel('Source project', { exact: true }).inputValue(),
'8',
);
assert.equal(
await page
.getByRole('checkbox', { name: 'Board columns', exact: true })
.isChecked(),
true,
);
assert.equal(
await page
.getByRole('checkbox', {
name: 'Custom ticket fields',
exact: true,
})
.isChecked(),
true,
);
await setResult('validation');
const duplicateRequest = page.waitForRequest(
(request) => request.method() === 'POST',
);
await submit();
const duplicateData = (await duplicateRequest).postDataJSON();
assert.equal(duplicateData.source_project_id, '8');
assert.equal(duplicateData.source_template_id, '');
assert.deepEqual(duplicateData.copy_areas, [
'board_columns',
'ticket_fields',
]);
await state('validation');
assert.equal(
await page.getByLabel('Project name', { exact: true }).inputValue(),
'Frontend workspace',
);
assert.equal(
await page
.getByLabel('Project key', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await page
.getByText('The key has already been taken.', { exact: true })
.waitFor();
await page
.getByText('Select a valid configuration area.', { exact: true })
.waitFor();
assert.equal(
await page
.getByRole('checkbox', { name: 'Board columns', exact: true })
.getAttribute('aria-invalid'),
'true',
);
await choose('scratch');
assert.equal(
await page
.getByText('Select a valid configuration area.', { exact: true })
.count(),
0,
);
assert.equal(
await page
.getByLabel('Project key', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await choose('duplicate');
assert.equal(
await page
.getByRole('checkbox', { name: 'Board columns', exact: true })
.isChecked(),
true,
);
assert.equal(
await page
.getByRole('checkbox', { name: 'Board columns', exact: true })
.getAttribute('aria-invalid'),
'false',
);
await page.route('**/projects', (route) => route.abort('failed'));
await submit();
await state('network');
assert.equal(
await page.getByLabel('Project name', { exact: true }).inputValue(),
'Frontend workspace',
);
await page.unroute('**/projects');
await setResult('server');
await page.getByRole('button', { name: 'Try again' }).click();
await state('server');
await setResult('success');
let pending;
let submissions = 0;
await page.route('**/projects', (route) => {
submissions++;
pending = route;
});
await page.getByRole('button', { name: 'Try again' }).click();
await state('loading');
assert.equal(
await page
.getByRole('button', { name: 'Creating project…', exact: true })
.isDisabled(),
true,
);
assert.equal(
await page.getByLabel('Project name', { exact: true }).isDisabled(),
true,
);
await page.locator('form').evaluate((form) => form.requestSubmit());
assert.equal(submissions, 1);
await pending.continue();
await page.waitForURL('**/projects/99');
await page.unroute('**/projects');
await page.goto(`${base}/projects/create`);
await choose('duplicate');
await fillDetails();
const emptyCopyRequest = page.waitForRequest(
(request) => request.method() === 'POST',
);
await submit();
assert.deepEqual((await emptyCopyRequest).postDataJSON().copy_areas, []);
await page.waitForURL('**/projects/99');
assert.deepEqual(errors, []);
console.log(
'Project creation browser checks passed: all modes, preselection, payloads, retained input, validation, retry, duplicate submission protection, keyboard, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
@@ -1,19 +0,0 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Create from '../../resources/js/pages/projects/Create.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Create'
? Create
: { render: () => h('h1', 'Project created') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
-256
View File
@@ -1,256 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/project-creation-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const areas = [
'project_settings',
'board_columns',
'ticket_statuses',
'status_transitions',
'ticket_types',
'ticket_priorities',
'ticket_fields',
'ticket_link_types',
'repository_connections',
'notification_actions',
];
function makePage(url, errors = {}) {
const query = new URL(url, 'http://localhost').searchParams;
return {
component: url.startsWith('/projects/create')
? 'Create'
: 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors,
sidebarOpen: true,
auth: {
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
permissions: {
globalAdmin: false,
projectCreate: true,
canManageTeams: false,
},
},
navigation: { project: null },
copyAreaOptions: areas.map((value) => ({
value,
label: value,
description: 'Server description',
})),
sourceProjects: query.has('empty')
? []
: [
{
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
},
{ id: 8, key: 'OPS', name: 'Operations' },
],
sourceTemplates:
query.has('empty') || query.has('no-templates')
? []
: [
{
id: 3,
name: 'Team baseline',
slug: 'team-baseline',
copy_areas: ['board_columns', 'ticket_types'],
},
{
id: 4,
name: 'Minimal setup',
slug: 'minimal',
copy_areas: [],
},
],
selectedSourceTemplateId: query.has('template')
? Number(query.get('template'))
: null,
},
};
}
if (serve) {
// Real Vue/Inertia form with isolated transport; PHPUnit covers persisted projects.
server.middlewares.use(async (request, response, next) => {
if (!request.url.startsWith('/projects')) {
return next();
}
let url = request.url;
let errors = {};
if (request.method === 'POST') {
const result =
request.headers.cookie?.match(
/creation_test_result=(\w+)/,
)?.[1] ?? 'success';
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = JSON.parse(body);
if (result === 'server') {
response.statusCode = 503;
response.end('Simulated server failure');
return;
}
url = result === 'validation' ? '/projects/create' : '/projects/99';
if (result === 'validation') {
errors = {
key: 'The key has already been taken.',
...(data.creation_mode === 'duplicate'
? {
'copy_areas.0':
'Select a valid configuration area.',
}
: {}),
};
}
}
const page = makePage(url, errors);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/project-creation-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log(
'Project creation fixture ready at http://127.0.0.1:4179/projects/create',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const Create = (
await server.ssrLoadModule(
'/resources/js/pages/projects/Create.vue',
)
).default;
const render = async (
url,
errors = {},
locale = createLocalization(),
) => {
const response = await createInertiaApp({
page: makePage(url, errors),
resolve: () => Create,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance('light')),
});
return response.body.split('<main')[1].split('</main>')[0];
};
const empty = await render('/projects/create?empty');
assert.match(empty, /value="scratch"[^>]*checked/);
assert.match(empty, /value="duplicate"[^>]*disabled/);
assert.match(empty, /value="template"[^>]*disabled/);
assert.match(empty, /No accessible projects/);
assert.match(empty, /No published templates/);
assert.doesNotMatch(empty, /<select/);
const scratch = await render('/projects/create?no-templates');
assert.match(scratch, /value="duplicate"[^>]*aria-invalid="false"/);
assert.doesNotMatch(scratch, /value="duplicate"[^>]*disabled/);
const template = await render('/projects/create');
assert.match(template, /value="template"[^>]*checked/);
assert.match(template, /Team baseline/);
assert.match(template, /Board columns/);
assert.match(template, /Ticket types/);
assert.match(template, /The project stays linked to this template/);
assert.doesNotMatch(template, /Server description/);
const preselected = await render('/projects/create?template=4');
assert.match(preselected, /Minimal setup/);
assert.match(
preselected,
/This template uses the default project setup/,
);
for (const id of [
'name-error',
'key-help',
'key-error',
'template-help',
'source-template-error',
'creation-mode-error',
]) {
assert.ok(
template.includes(`id="${id}"`),
`Missing accessible feedback target: ${id}`,
);
}
const translated = await render(
'/projects/create',
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'projectCreation.title': 'Projekt anlegen',
'projectCreation.areas.boardColumns.label':
'Boardspalten',
},
},
),
);
assert.match(translated, /Projekt anlegen/);
assert.match(translated, /Boardspalten/);
console.log('Project creation rendering checks passed.');
} finally {
await server.close();
}
}
-167
View File
@@ -1,167 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4178';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const setRole = (value) =>
context.addCookies([{ name: 'project_test_role', value, url: base }]);
const content = () => page.locator('#main-content');
const focusWithTab = async (link) => {
await content().focus();
for (let step = 0; step < 30; step++) {
await page.keyboard.press('Tab');
if (
await link.evaluate((element) => element === document.activeElement)
) {
assert.equal(
await link.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Project action was not reachable by keyboard');
};
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
await setRole('admin');
for (const [path, title, label] of [
['/projects', 'Projects', 'index'],
[
'/projects/7?template',
'Engineering · Platform and developer experience',
'hub',
],
['/projects?empty', 'Projects', 'empty'],
]) {
await page.goto(base + path);
await page
.getByRole('heading', {
name: title,
exact: true,
level: 1,
})
.waitFor();
await page.evaluate(() => document.fonts.ready);
assert.equal(
await page
.locator('html')
.evaluate((element) =>
element.classList.contains('dark'),
),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${label} overflows at ${width} in ${theme}`,
);
assert.equal(
await content().locator('a a, a button').count(),
0,
);
await page.screenshot({
path: `/private/tmp/kan23-${label}-${width}-${theme}.png`,
fullPage: true,
});
}
await page.goto(`${base}/projects/7`);
const board = content().getByRole('link', {
name: 'Open board',
exact: true,
});
await focusWithTab(board);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/board');
await page
.getByRole('heading', { name: 'Destination reached' })
.waitFor();
}
}
await setRole('reader');
await page.goto(`${base}/projects`);
assert.equal(
await content()
.getByRole('link', { name: 'New project', exact: true })
.count(),
0,
);
const overview = content().getByRole('link', {
name: 'Open Engineering · Platform and developer experience overview',
exact: true,
});
await focusWithTab(overview);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7');
assert.equal(
await content()
.locator(
'a[href*="/settings/"], a[href$="/access"], a[href*="/templates/"]',
)
.count(),
0,
);
await content()
.getByRole('link', { name: 'Open backlog', exact: true })
.click();
await page.waitForURL('**/projects/7/backlog');
await setRole('admin');
await page.goto(`${base}/projects/7`);
const settings = content().getByRole('region', {
name: 'Project settings',
exact: true,
});
assert.equal(await settings.getByRole('link').count(), 6);
const general = settings.getByRole('link', {
name: 'General',
exact: true,
});
await focusWithTab(general);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/settings/general');
await setRole('creator');
await page.goto(`${base}/projects?empty`);
const create = content().getByRole('link', {
name: 'Create your first project',
exact: true,
});
await focusWithTab(create);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/create');
await page.goto(`${base}/projects/7`);
await content()
.getByRole('link', { name: 'Save as new template', exact: true })
.click();
await page.waitForURL('**/projects/7/templates/create');
assert.deepEqual(errors, []);
console.log(
'Project overview browser checks passed: keyboard, navigation, permissions, empty states, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
@@ -1,20 +0,0 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Index from '../../resources/js/pages/projects/Index.vue';
import Show from '../../resources/js/pages/projects/Show.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
({ Index, Show })[name] ?? {
render: () => h('h1', 'Destination reached'),
},
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
-263
View File
@@ -1,263 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { createInertiaApp } from '@inertiajs/vue3';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/project-overview-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4178, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const project = {
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
};
const secondProject = { id: 8, key: 'OPS', name: 'Operations' };
const templateState = {
inheritsFromTemplate: true,
templateName: 'Team baseline',
inheritedAreas: [{ value: 'board_columns', label: 'Board columns' }],
overriddenAreas: [{ value: 'ticket_types', label: 'Ticket types' }],
};
function makePage(
url,
{
manage = false,
create = false,
empty = false,
template = false,
status = null,
} = {},
) {
const path = new URL(url, 'http://localhost').pathname;
const isIndex = path === '/projects';
const isShow = path === '/projects/7';
return {
component: isIndex ? 'Index' : isShow ? 'Show' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
auth: {
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
permissions: {
globalAdmin: false,
projectCreate: create,
canManageTeams: false,
},
},
navigation: {
project: isIndex
? null
: {
...project,
canManage: manage,
canCreateTickets: manage,
},
},
...(isIndex
? { projects: empty ? [] : [project, secondProject], status }
: {
project,
canManageAccess: manage,
templateState: template ? templateState : null,
}),
},
};
}
if (serve) {
// Isolated transport for real Vue pages. PHPUnit covers actual permissions and resources.
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (!url.pathname.startsWith('/projects')) {
return next();
}
const role = request.headers.cookie?.match(
/project_test_role=(\w+)/,
)?.[1];
const page = makePage(request.url, {
manage: role === 'admin',
create: role === 'creator',
empty: url.searchParams.has('empty'),
template: url.searchParams.has('template'),
});
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/project-overview-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log(
'Project overview fixture ready at http://127.0.0.1:4178/projects',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { en } = await server.ssrLoadModule(
'/resources/js/locales/en.ts',
);
const pages = Object.fromEntries(
await Promise.all(
['Index', 'Show'].map(async (name) => [
name,
(
await server.ssrLoadModule(
`/resources/js/pages/projects/${name}.vue`,
)
).default,
]),
),
);
const render = async (
url,
options = {},
locale = createLocalization(),
) => {
const response = await createInertiaApp({
page: makePage(url, options),
resolve: (name) => pages[name],
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(locale)
.use(createAppearance('light')),
});
return response.body.split('<main')[1].split('</main>')[0];
};
const index = await render('/projects');
assert.match(index, /2 projects available/);
for (const id of [7, 8]) {
for (const suffix of ['', '/board', '/backlog']) {
assert.ok(index.includes(`href="/projects/${id}${suffix}"`));
}
}
assert.doesNotMatch(
index,
/href="\/projects\/create"|upcoming|will later/,
);
const empty = await render('/projects', { empty: true });
assert.match(empty, /No projects to show/);
assert.match(empty, /Ask a workspace or project administrator/);
assert.doesNotMatch(empty, /href="\/projects\/create"|<article/);
const creator = await render('/projects', {
empty: true,
create: true,
status: 'Project deleted.',
});
assert.match(creator, /href="\/projects\/create"/);
assert.match(creator, /Create your first project/);
assert.match(creator, /role="status"[^>]*data-feedback="success"/);
assert.match(creator, /Project deleted\./);
const reader = await render('/projects/7');
assert.match(reader, /aria-label="Open board"/);
assert.match(reader, /aria-label="Open backlog"/);
assert.doesNotMatch(
reader,
/href="\/projects\/7\/(settings|access|templates)/,
);
const admin = await render('/projects/7', {
manage: true,
template: true,
});
for (const suffix of [
'settings/general',
'settings/board',
'settings/metadata',
'settings/repositories',
'settings/notifications',
'access',
]) {
assert.ok(admin.includes(`href="/projects/7/${suffix}"`));
}
assert.doesNotMatch(admin, /href="\/projects\/7\/templates/);
assert.match(admin, /Based on Team baseline/);
assert.match(admin, /Board columns · inherited/);
assert.match(admin, /Ticket types · overridden/);
assert.match(
await render('/projects/7', { create: true }),
/href="\/projects\/7\/templates\/create"/,
);
const translated = await render(
'/projects',
{},
createLocalization(
{ locale: 'de' },
{
de: {
...en,
'navigation.projects': 'Projekte',
'projects.overview': 'Projektübersicht',
},
},
),
);
assert.match(translated, /Projekte/);
assert.match(translated, /Projektübersicht/);
assert.doesNotMatch(
index + reader + admin,
/upcoming|will later|Use the project shortcuts/,
);
let insideLink = false;
for (const [tag] of (index + reader + admin).matchAll(
/<\/?a\b[^>]*>/g,
)) {
if (tag.startsWith('</')) {
insideLink = false;
} else {
assert.equal(insideLink, false, 'Links must not be nested');
insideLink = true;
}
}
console.log('Project overview behavior checks passed.');
} finally {
await server.close();
}
}
@@ -1,11 +0,0 @@
import { createApp } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import TicketPresentationsPreview from './TicketPresentationsPreview.vue';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: () => TicketPresentationsPreview,
setup: ({ el, App, props, plugin }) =>
createApp(App, props).use(plugin).use(createLocalization()).mount(el),
});
-228
View File
@@ -1,228 +0,0 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/ticket-presentations-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 8,
name: 'Taylor Reed',
email: 'taylor@example.test',
avatar: null,
avatar_color: '#22c55e',
avatar_is_generated: true,
email_verified_at: null,
must_change_password_on_next_login: false,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const fullTicket = {
id: 42,
acceptance_criteria: [],
assignee: user,
assignee_user_id: user.id,
author: null,
author_user_id: null,
blocked_comment: 'Waiting for the security review.',
description: 'Keep the complete summary available on every surface.',
is_blocked: true,
parent_ticket_id: null,
project_id: 7,
sequence_number: 42,
story_points: 5,
ticket_key: 'ENG-42',
ticket_priority: {
id: 502,
name: 'Critical from project',
position: 4,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_priority_id: 502,
ticket_status: {
id: 602,
board_column_id: 12,
name: 'Review from project',
position: 2,
project_id: 7,
slug: 'review',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
board_column: {
id: 12,
name: 'Delivery',
position: 2,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
},
ticket_status_id: 602,
ticket_type: {
id: 702,
is_parent: false,
name: 'Incident from project',
position: 1,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_type_id: 702,
title: 'A very long ticket title with an_uninterrupted_identifier_that_must_remain_readable_and_actionable_on_small_screens',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const optionalTicket = {
...fullTicket,
id: 43,
assignee: null,
assignee_user_id: null,
blocked_comment: null,
description: null,
is_blocked: false,
ticket_key: 'ENG-43',
ticket_status: null,
ticket_status_id: null,
title: 'Ticket without optional data',
};
if (serve) {
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (url.pathname !== '/ticket-presentations') {
return next();
}
const page = {
component: 'TicketPresentationsPreview',
url: request.url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
auth: { user },
errors: {},
fullTicket,
optionalTicket,
},
};
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
`<!doctype html><html lang="en"${url.searchParams.has('dark') ? ' class="dark"' : ''}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="${escaped}"></div><script type="module" src="/tests/Fixtures/ticket-presentations-preview.mjs"></script></body></html>`,
),
);
});
await server.listen();
console.log(
'Ticket presentation fixture ready at http://127.0.0.1:4179/ticket-presentations',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { default: TicketCard } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketCard.vue',
);
const { default: TicketListItem } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketListItem.vue',
);
assert.deepEqual(TicketCard.emits, ['open']);
assert.deepEqual(TicketListItem.emits, ['open']);
for (const component of [TicketCard, TicketListItem]) {
assert.ok(component.props.projectId);
assert.ok(component.props.ticket);
assert.ok(component.props.showDescription);
assert.ok(component.props.showStatus);
}
const render = async (component, ticket) => {
const Page = {
render: () =>
component === TicketListItem
? h('ul', {}, [h(component, { projectId: 7, ticket })])
: h(component, { projectId: 7, ticket }),
};
const response = await createInertiaApp({
page: {
component: 'TicketPresentation',
url: '/projects/7/board',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
auth: { user },
errors: {},
},
},
resolve: () => Page,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization()),
});
return response.body;
};
for (const component of [TicketCard, TicketListItem]) {
const html = await render(component, fullTicket);
assert.match(html, /href="\/projects\/7\/tickets\/42"/);
assert.match(html, /aria-label="Open ENG-42:/);
assert.match(html, /min-h-control/);
assert.match(html, /focus-visible:ring-2/);
assert.match(html, /wrap-anywhere/);
assert.match(html, new RegExp(fullTicket.title));
assert.match(html, /data-ticket-type-id="702"/);
assert.match(html, /Incident from project/);
assert.match(html, /data-ticket-priority-id="502"/);
assert.match(html, /Critical from project/);
assert.match(html, /data-ticket-status-id="602"/);
assert.match(html, /Delivery \/ Review from project/);
assert.match(html, /Assigned to Taylor Reed/);
assert.match(html, /Blocked/);
assert.match(html, /Blocker: Waiting for the security review\./);
const optionalHtml = await render(component, optionalTicket);
assert.match(optionalHtml, /href="\/projects\/7\/tickets\/43"/);
assert.match(optionalHtml, /Ticket without optional data/);
assert.match(optionalHtml, /No status/);
assert.match(optionalHtml, /Unassigned/);
assert.doesNotMatch(optionalHtml, /Blocked|Blocker:/);
}
console.log('Ticket presentation behavior checks passed.');
} finally {
await server.close();
}
}