Compare commits
4
Commits
v0.2.1
..
718f6b991d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718f6b991d | ||
|
|
1b2349e977 | ||
|
|
95db152b91 | ||
|
|
5194ed0a17 |
@@ -47,7 +47,6 @@ class BuildProjectBacklogPayload
|
|||||||
'tickets' => TicketResource::collection(
|
'tickets' => TicketResource::collection(
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->with([
|
->with([
|
||||||
'assignee',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
'assignee',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
|||||||
->where('assignee_user_id', $assignee->id)
|
->where('assignee_user_id', $assignee->id)
|
||||||
->whereIn('project_id', $visibleProjectIds)
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
->with([
|
->with([
|
||||||
'acceptanceCriteria',
|
|
||||||
'assignee',
|
'assignee',
|
||||||
|
'project',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,78 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
|
||||||
import { login } from '@/routes/index';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
state: FormFeedbackState;
|
|
||||||
responseStatus?: number | null;
|
|
||||||
loadingTitle?: string;
|
|
||||||
successTitle?: string;
|
|
||||||
}>();
|
|
||||||
defineEmits<{ retry: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const expired = computed(
|
|
||||||
() => props.state === 'server' && props.responseStatus === 419,
|
|
||||||
);
|
|
||||||
const limited = computed(
|
|
||||||
() => props.state === 'server' && props.responseStatus === 429,
|
|
||||||
);
|
|
||||||
const title = computed(() => {
|
|
||||||
if (expired.value) {
|
|
||||||
return t('auth.feedback.expiredTitle');
|
|
||||||
}
|
|
||||||
if (limited.value) {
|
|
||||||
return t('auth.feedback.limitedTitle');
|
|
||||||
}
|
|
||||||
if (props.state === 'loading') {
|
|
||||||
return props.loadingTitle ?? t('auth.feedback.pending');
|
|
||||||
}
|
|
||||||
if (props.state === 'success') {
|
|
||||||
return props.successTitle ?? t('auth.feedback.completed');
|
|
||||||
}
|
|
||||||
if (props.state === 'validation') {
|
|
||||||
return t('auth.feedback.validationTitle');
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
const description = computed(() => {
|
|
||||||
if (expired.value) {
|
|
||||||
return t('auth.feedback.expiredDescription');
|
|
||||||
}
|
|
||||||
if (limited.value) {
|
|
||||||
return t('auth.feedback.limitedDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'validation') {
|
|
||||||
return t('auth.feedback.validationDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'network') {
|
|
||||||
return t('auth.feedback.networkDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'server') {
|
|
||||||
return t('auth.feedback.serverDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'cancelled') {
|
|
||||||
return t('auth.feedback.cancelledDescription');
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="gap-card grid" v-show="state !== 'idle'">
|
|
||||||
<FormFeedback
|
|
||||||
:state="state"
|
|
||||||
:title="title"
|
|
||||||
:description="description"
|
|
||||||
:retryable="!expired"
|
|
||||||
@retry="$emit('retry')"
|
|
||||||
/>
|
|
||||||
<Button v-if="expired" as-child variant="outline">
|
|
||||||
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
|
|
||||||
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
state: 'empty' | 'loading' | 'error' | 'success';
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
retryable?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ retryable: false },
|
|
||||||
);
|
|
||||||
defineEmits<{ retry: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const icons = {
|
|
||||||
empty: Inbox,
|
|
||||||
loading: LoaderCircle,
|
|
||||||
error: CircleAlert,
|
|
||||||
success: CircleCheck,
|
|
||||||
};
|
|
||||||
const defaultTitles = {
|
|
||||||
empty: 'feedback.empty',
|
|
||||||
loading: 'common.loading',
|
|
||||||
error: 'common.error',
|
|
||||||
success: 'feedback.saved',
|
|
||||||
} as const;
|
|
||||||
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:role="state === 'error' ? 'alert' : 'status'"
|
|
||||||
:aria-busy="state === 'loading' || undefined"
|
|
||||||
:data-feedback="state"
|
|
||||||
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
|
|
||||||
:class="{
|
|
||||||
'border-destructive/40': state === 'error',
|
|
||||||
'border-success/40': state === 'success',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icons[state]"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="mt-0.5 size-5 shrink-0"
|
|
||||||
:class="{
|
|
||||||
'text-primary animate-spin motion-reduce:animate-none':
|
|
||||||
state === 'loading',
|
|
||||||
'text-destructive': state === 'error',
|
|
||||||
'text-success-foreground': state === 'success',
|
|
||||||
'text-muted-foreground': state === 'empty',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<div class="gap-stack grid min-w-0 flex-1 break-words">
|
|
||||||
<p class="font-semibold">{{ heading }}</p>
|
|
||||||
<p v-if="description" class="text-muted-foreground">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
<div v-if="$slots.default"><slot /></div>
|
|
||||||
<Button
|
|
||||||
v-if="retryable && state === 'error'"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
class="justify-self-start"
|
|
||||||
@click="$emit('retry')"
|
|
||||||
>{{ t('common.retry') }}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
state: FormFeedbackState;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
retryable?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ retryable: true },
|
|
||||||
);
|
|
||||||
defineEmits<{ retry: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const feedback = useTemplateRef<HTMLDivElement>('feedback');
|
|
||||||
watch(
|
|
||||||
() => props.state,
|
|
||||||
async (state) => {
|
|
||||||
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
|
|
||||||
await nextTick();
|
|
||||||
if (props.state === state) {
|
|
||||||
feedback.value?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const titles = {
|
|
||||||
idle: 'feedback.saved',
|
|
||||||
loading: 'forms.saving',
|
|
||||||
success: 'feedback.saved',
|
|
||||||
validation: 'forms.validationTitle',
|
|
||||||
network: 'forms.networkTitle',
|
|
||||||
server: 'forms.serverTitle',
|
|
||||||
cancelled: 'forms.cancelledTitle',
|
|
||||||
} as const;
|
|
||||||
const descriptions = {
|
|
||||||
validation: 'forms.validationDescription',
|
|
||||||
network: 'forms.networkDescription',
|
|
||||||
server: 'forms.serverDescription',
|
|
||||||
cancelled: 'forms.cancelledDescription',
|
|
||||||
} as const;
|
|
||||||
const description = computed(() =>
|
|
||||||
props.state in descriptions
|
|
||||||
? t(descriptions[props.state as keyof typeof descriptions])
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="feedback"
|
|
||||||
v-show="state !== 'idle'"
|
|
||||||
:data-form-state="state"
|
|
||||||
tabindex="-1"
|
|
||||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
|
||||||
>
|
|
||||||
<FeedbackState
|
|
||||||
v-if="state !== 'idle'"
|
|
||||||
:state="
|
|
||||||
state === 'loading' || state === 'success' ? state : 'error'
|
|
||||||
"
|
|
||||||
:title="props.title ?? t(titles[state])"
|
|
||||||
:description="props.description ?? description"
|
|
||||||
:retryable="
|
|
||||||
props.retryable !== false &&
|
|
||||||
(state === 'network' ||
|
|
||||||
state === 'server' ||
|
|
||||||
state === 'cancelled')
|
|
||||||
"
|
|
||||||
@retry="$emit('retry')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -5,8 +5,8 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-show="message" role="alert">
|
<div v-show="message">
|
||||||
<p class="text-body text-destructive">
|
<p class="text-sm text-red-600 dark:text-red-500">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { Eye, EyeOff } from 'lucide-vue-next';
|
import { Eye, EyeOff } from 'lucide-vue-next';
|
||||||
import { ref, useTemplateRef } from 'vue';
|
import { ref, useTemplateRef } from 'vue';
|
||||||
import type { HTMLAttributes } from 'vue';
|
import type { HTMLAttributes } from 'vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -10,14 +9,12 @@ defineOptions({ inheritAttrs: false });
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes['class'];
|
class?: HTMLAttributes['class'];
|
||||||
disabled?: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const model = defineModel<string | number | null>();
|
const model = defineModel<string | number | null>();
|
||||||
|
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const inputRef = useTemplateRef('inputRef');
|
const inputRef = useTemplateRef('inputRef');
|
||||||
const { t } = useLocalization();
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
$el: inputRef,
|
$el: inputRef,
|
||||||
@@ -31,8 +28,7 @@ defineExpose({
|
|||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="model"
|
v-model="model"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
:class="cn('pr-12', props.class)"
|
:class="cn('pr-10', props.class)"
|
||||||
:disabled="disabled"
|
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -40,14 +36,11 @@ defineExpose({
|
|||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
:aria-label="
|
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||||
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
:tabindex="-1"
|
||||||
"
|
|
||||||
:aria-pressed="showPassword"
|
|
||||||
:disabled="disabled"
|
|
||||||
>
|
>
|
||||||
<EyeOff v-if="showPassword" class="size-4" />
|
<EyeOff v-if="showPassword" class="size-4" />
|
||||||
<Eye v-else class="size-4" />
|
<Eye v-else class="size-4" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as TicketCard } from './TicketCard.vue';
|
|
||||||
export { default as TicketListItem } from './TicketListItem.vue';
|
|
||||||
@@ -1,41 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { X } from "lucide-vue-next";
|
import { X } from "lucide-vue-next"
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import DialogOverlay from "./DialogOverlay.vue";
|
import DialogOverlay from "./DialogOverlay.vue"
|
||||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
|
||||||
import { useLocalization } from "@/composables/useLocalization";
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||||
defineProps<
|
|
||||||
DialogContentProps & {
|
|
||||||
class?: HTMLAttributes["class"];
|
|
||||||
showCloseButton?: boolean;
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
{
|
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
},
|
})
|
||||||
);
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
const emits = defineEmits<DialogContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
const { restoreFocus } = useDialogFocus();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,23 +32,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
@close-auto-focus="restoreFocus"
|
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
<span class="sr-only">{{ t("common.close") }}</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"];
|
class?: HTMLAttributes["class"]
|
||||||
}>();
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { X } from "lucide-vue-next";
|
import { X } from "lucide-vue-next"
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
|
||||||
import { useLocalization } from "@/composables/useLocalization";
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
DialogContentProps & { class?: HTMLAttributes["class"] }
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
>();
|
|
||||||
const emits = defineEmits<DialogContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
const { restoreFocus } = useDialogFocus();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -38,32 +32,26 @@ const { t } = useLocalization();
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
@close-auto-focus="restoreFocus"
|
@pointer-down-outside="(event) => {
|
||||||
@pointer-down-outside="
|
|
||||||
(event) => {
|
|
||||||
const originalEvent = event.detail.originalEvent;
|
const originalEvent = event.detail.originalEvent;
|
||||||
const target = originalEvent.target as HTMLElement;
|
const target = originalEvent.target as HTMLElement;
|
||||||
if (
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
originalEvent.offsetX > target.clientWidth ||
|
|
||||||
originalEvent.offsetY > target.clientHeight
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
<span class="sr-only">{{ t("common.close") }}</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
|
|||||||
@@ -1,42 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||||
DropdownMenuCheckboxItemEmits,
|
import type { HTMLAttributes } from "vue"
|
||||||
DropdownMenuCheckboxItemProps,
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
} from "reka-ui";
|
import { Check } from "lucide-vue-next"
|
||||||
import type { HTMLAttributes } from "vue";
|
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
|
||||||
import { Check } from "lucide-vue-next";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuItemIndicator,
|
DropdownMenuItemIndicator,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
|
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||||
>();
|
|
||||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class=" cn(
|
||||||
cn(
|
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)"
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
|
||||||
>
|
>
|
||||||
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuItemIndicator>
|
<DropdownMenuItemIndicator>
|
||||||
<slot name="indicator-icon">
|
<slot name="indicator-icon">
|
||||||
<Check class="size-4" />
|
<Check class="size-4" />
|
||||||
|
|||||||
@@ -1,34 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||||
DropdownMenuContentEmits,
|
import type { HTMLAttributes } from "vue"
|
||||||
DropdownMenuContentProps,
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
} from "reka-ui";
|
|
||||||
import type { HTMLAttributes } from "vue";
|
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<
|
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
|
||||||
>(),
|
|
||||||
{
|
{
|
||||||
sideOffset: 4,
|
sideOffset: 4,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,12 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||||
cn(
|
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuItemProps } from "reka-ui";
|
import type { DropdownMenuItemProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||||
defineProps<
|
class?: HTMLAttributes["class"]
|
||||||
DropdownMenuItemProps & {
|
inset?: boolean
|
||||||
class?: HTMLAttributes["class"];
|
variant?: "default" | "destructive"
|
||||||
inset?: boolean;
|
}>(), {
|
||||||
variant?: "default" | "destructive";
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
{
|
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
|
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps);
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -29,12 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps);
|
|||||||
:data-inset="inset ? '' : undefined"
|
:data-inset="inset ? '' : undefined"
|
||||||
:data-variant="variant"
|
:data-variant="variant"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||||
cn(
|
|
||||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,43 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
|
||||||
DropdownMenuRadioItemEmits,
|
import type { HTMLAttributes } from "vue"
|
||||||
DropdownMenuRadioItemProps,
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
} from "reka-ui";
|
import { Circle } from "lucide-vue-next"
|
||||||
import type { HTMLAttributes } from "vue";
|
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
|
||||||
import { Circle } from "lucide-vue-next";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenuItemIndicator,
|
DropdownMenuItemIndicator,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
|
|
||||||
>();
|
|
||||||
|
|
||||||
const emits = defineEmits<DropdownMenuRadioItemEmits>();
|
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="cn(
|
||||||
cn(
|
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)"
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
|
||||||
>
|
>
|
||||||
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuItemIndicator>
|
<DropdownMenuItemIndicator>
|
||||||
<slot name="indicator-icon">
|
<slot name="indicator-icon">
|
||||||
<Circle class="size-2 fill-current" />
|
<Circle class="size-2 fill-current" />
|
||||||
|
|||||||
@@ -1,33 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||||
DropdownMenuSubContentEmits,
|
import type { HTMLAttributes } from "vue"
|
||||||
DropdownMenuSubContentProps,
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
} from "reka-ui";
|
import {
|
||||||
import type { HTMLAttributes } from "vue";
|
DropdownMenuSubContent,
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
useForwardPropsEmits,
|
||||||
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
|
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||||
>();
|
|
||||||
const emits = defineEmits<DropdownMenuSubContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuSubContent
|
<DropdownMenuSubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||||
cn(
|
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuSubTriggerProps } from "reka-ui";
|
import type { DropdownMenuSubTriggerProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { ChevronRight } from "lucide-vue-next";
|
import { ChevronRight } from "lucide-vue-next"
|
||||||
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
|
import {
|
||||||
import { cn } from "@/lib/utils";
|
DropdownMenuSubTrigger,
|
||||||
|
useForwardProps,
|
||||||
|
} from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
|
||||||
DropdownMenuSubTriggerProps & {
|
|
||||||
class?: HTMLAttributes["class"];
|
|
||||||
inset?: boolean;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "inset");
|
const delegatedProps = reactiveOmit(props, "class", "inset")
|
||||||
const forwardedProps = useForwardProps(delegatedProps);
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuSubTrigger
|
<DropdownMenuSubTrigger
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="
|
:class="cn(
|
||||||
cn(
|
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<ChevronRight class="ml-auto size-4" />
|
<ChevronRight class="ml-auto size-4" />
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { X } from "lucide-vue-next";
|
import { X } from "lucide-vue-next"
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import SheetOverlay from "./SheetOverlay.vue";
|
import SheetOverlay from "./SheetOverlay.vue"
|
||||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
|
||||||
import { useLocalization } from "@/composables/useLocalization";
|
|
||||||
|
|
||||||
interface SheetContentProps extends DialogContentProps {
|
interface SheetContentProps extends DialogContentProps {
|
||||||
class?: HTMLAttributes["class"];
|
class?: HTMLAttributes["class"]
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
side: "right",
|
side: "right",
|
||||||
});
|
})
|
||||||
const emits = defineEmits<DialogContentEmits>();
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "side");
|
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
const { restoreFocus } = useDialogFocus();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,30 +36,26 @@ const { t } = useLocalization();
|
|||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
:class="
|
:class="cn(
|
||||||
cn(
|
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
side === 'right' &&
|
side === 'right'
|
||||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||||
side === 'left' &&
|
side === 'left'
|
||||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
side === 'top' &&
|
side === 'top'
|
||||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
side === 'bottom' &&
|
side === 'bottom'
|
||||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
props.class,
|
props.class)"
|
||||||
)
|
|
||||||
"
|
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
@close-auto-focus="restoreFocus"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||||
>
|
>
|
||||||
<X class="size-4" />
|
<X class="size-4" />
|
||||||
<span class="sr-only">{{ t("common.close") }}</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { injectDialogRootContext } from 'reka-ui';
|
|
||||||
import { watch } from 'vue';
|
|
||||||
|
|
||||||
/** Reka restores registered triggers; also support dialogs opened programmatically. */
|
|
||||||
export function useDialogFocus() {
|
|
||||||
const context = injectDialogRootContext();
|
|
||||||
let previousFocus: HTMLElement | null = null;
|
|
||||||
|
|
||||||
watch(
|
|
||||||
context.open,
|
|
||||||
(open) => {
|
|
||||||
if (open && typeof document !== 'undefined') {
|
|
||||||
previousFocus =
|
|
||||||
document.activeElement instanceof HTMLElement
|
|
||||||
? document.activeElement
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ flush: 'sync', immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
function restoreFocus(event: Event): void {
|
|
||||||
if (
|
|
||||||
!event.defaultPrevented &&
|
|
||||||
!context.triggerElement.value &&
|
|
||||||
previousFocus?.isConnected
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
previousFocus.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { restoreFocus };
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import type {
|
|
||||||
PendingVisit,
|
|
||||||
UrlMethodPair,
|
|
||||||
UseFormSubmitOptions,
|
|
||||||
} from '@inertiajs/core';
|
|
||||||
import { router } from '@inertiajs/vue3';
|
|
||||||
import type { InertiaForm } from '@inertiajs/vue3';
|
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|
||||||
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
|
|
||||||
import {
|
|
||||||
formRequestHeader,
|
|
||||||
isFormNetworkFailure,
|
|
||||||
matchesFormRequest,
|
|
||||||
} from '@/lib/form-feedback';
|
|
||||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
|
||||||
|
|
||||||
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
|
|
||||||
export function useFormProtection<T extends object>(
|
|
||||||
form: InertiaForm<T>,
|
|
||||||
options: { isDirty?: () => boolean } = {},
|
|
||||||
) {
|
|
||||||
const failure = ref<FormFeedbackState | null>(null);
|
|
||||||
const responseStatus = ref<number | null>(null);
|
|
||||||
let ownVisit: PendingVisit | null = null;
|
|
||||||
let requestId: string | null = null;
|
|
||||||
const removeListeners: Array<() => void> = [];
|
|
||||||
|
|
||||||
const { confirmLeave } = useUnsavedChanges(
|
|
||||||
() => form.processing || (options.isDirty?.() ?? form.isDirty),
|
|
||||||
(visit) => visit === ownVisit,
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = computed<FormFeedbackState>(() => {
|
|
||||||
if (form.processing) {
|
|
||||||
return 'loading';
|
|
||||||
}
|
|
||||||
if (failure.value) {
|
|
||||||
return failure.value;
|
|
||||||
}
|
|
||||||
if (Object.keys(form.errors).length > 0) {
|
|
||||||
return 'validation';
|
|
||||||
}
|
|
||||||
return form.recentlySuccessful ? 'success' : 'idle';
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
removeListeners.push(
|
|
||||||
router.on('exception', (event) => {
|
|
||||||
if (isFormNetworkFailure(event.detail.exception, requestId)) {
|
|
||||||
event.preventDefault();
|
|
||||||
failure.value = 'network';
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
router.on('invalid', (event) => {
|
|
||||||
if (
|
|
||||||
matchesFormRequest(event.detail.response.config, requestId)
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
failure.value = 'server';
|
|
||||||
responseStatus.value = event.detail.response.status;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => removeListeners.forEach((remove) => remove()));
|
|
||||||
|
|
||||||
function submit(
|
|
||||||
target: UrlMethodPair,
|
|
||||||
visitOptions: UseFormSubmitOptions = {},
|
|
||||||
): void {
|
|
||||||
if (form.processing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextRequestId = crypto.randomUUID();
|
|
||||||
form.submit(target, {
|
|
||||||
...visitOptions,
|
|
||||||
preserveState: true,
|
|
||||||
headers: {
|
|
||||||
...visitOptions.headers,
|
|
||||||
[formRequestHeader]: nextRequestId,
|
|
||||||
},
|
|
||||||
onBefore: (visit) => {
|
|
||||||
if (visitOptions.onBefore?.(visit) === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
ownVisit = visit;
|
|
||||||
},
|
|
||||||
onStart: (visit) => {
|
|
||||||
requestId = nextRequestId;
|
|
||||||
failure.value = null;
|
|
||||||
responseStatus.value = null;
|
|
||||||
visitOptions.onStart?.(visit);
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
failure.value = 'validation';
|
|
||||||
return visitOptions.onError?.(errors);
|
|
||||||
},
|
|
||||||
onSuccess: (page) => {
|
|
||||||
failure.value = null;
|
|
||||||
return visitOptions.onSuccess?.(page);
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
failure.value = 'cancelled';
|
|
||||||
visitOptions.onCancel?.();
|
|
||||||
},
|
|
||||||
onFinish: (visit) => {
|
|
||||||
requestId = null;
|
|
||||||
ownVisit = null;
|
|
||||||
visitOptions.onFinish?.(visit);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFeedback(): void {
|
|
||||||
failure.value = null;
|
|
||||||
responseStatus.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { state, responseStatus, submit, confirmLeave, clearFeedback };
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { PendingVisit } from '@inertiajs/core';
|
|
||||||
import { router } from '@inertiajs/vue3';
|
|
||||||
import { onMounted, onUnmounted, toValue } from 'vue';
|
|
||||||
import type { MaybeRefOrGetter } from 'vue';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
export function useUnsavedChanges(
|
|
||||||
dirty: MaybeRefOrGetter<boolean>,
|
|
||||||
allowVisit: (visit: PendingVisit) => boolean = () => false,
|
|
||||||
) {
|
|
||||||
const { t } = useLocalization();
|
|
||||||
let removeBeforeListener: (() => void) | undefined;
|
|
||||||
|
|
||||||
const confirmLeave = (): boolean =>
|
|
||||||
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
|
|
||||||
|
|
||||||
const beforeUnload = (event: BeforeUnloadEvent): void => {
|
|
||||||
if (toValue(dirty)) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
removeBeforeListener = router.on('before', (event) => {
|
|
||||||
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the original visit and its callbacks; never replay a mutation.
|
|
||||||
return confirmLeave();
|
|
||||||
});
|
|
||||||
window.addEventListener('beforeunload', beforeUnload);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
removeBeforeListener?.();
|
|
||||||
window.removeEventListener('beforeunload', beforeUnload);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { confirmLeave };
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
|
||||||
import type { Component } from 'vue';
|
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { home } from '@/routes/index';
|
|
||||||
|
|
||||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
|
||||||
<header
|
|
||||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
:href="home()"
|
|
||||||
:aria-label="t('auth.home')"
|
|
||||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
|
||||||
><AppLogo
|
|
||||||
/></Link>
|
|
||||||
</header>
|
|
||||||
<main
|
|
||||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
aria-labelledby="auth-title"
|
|
||||||
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
|
|
||||||
>
|
|
||||||
<div class="gap-column grid">
|
|
||||||
<span
|
|
||||||
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icon ?? ShieldCheck"
|
|
||||||
class="size-5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<div class="gap-stack grid">
|
|
||||||
<p
|
|
||||||
class="text-label text-muted-foreground uppercase tracking-wide"
|
|
||||||
>
|
|
||||||
{{ t('auth.accountAccess') }}
|
|
||||||
</p>
|
|
||||||
<h1
|
|
||||||
id="auth-title"
|
|
||||||
class="text-headline-mobile sm:text-headline"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-body text-muted-foreground">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
<div
|
|
||||||
v-if="$slots.footer"
|
|
||||||
class="px-card text-body text-muted-foreground text-center"
|
|
||||||
>
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer class="pb-page flex justify-center px-4">
|
|
||||||
<AppearanceTabs />
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
export type FormFeedbackState =
|
|
||||||
| 'idle'
|
|
||||||
| 'loading'
|
|
||||||
| 'success'
|
|
||||||
| 'validation'
|
|
||||||
| 'network'
|
|
||||||
| 'server'
|
|
||||||
| 'cancelled';
|
|
||||||
|
|
||||||
export const formRequestHeader = 'X-KanbanGit-Form-Request';
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Match failures to one submission, without intercepting other forms or polling. */
|
|
||||||
export function matchesFormRequest(
|
|
||||||
config: unknown,
|
|
||||||
requestId: string | null,
|
|
||||||
): boolean {
|
|
||||||
if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.headers[formRequestHeader] === requestId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runtime exceptions and cancellations are not network failures. */
|
|
||||||
export function isFormNetworkFailure(
|
|
||||||
error: unknown,
|
|
||||||
requestId: string | null,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
isRecord(error) &&
|
|
||||||
error.isAxiosError === true &&
|
|
||||||
!error.response &&
|
|
||||||
['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
|
|
||||||
String(error.code),
|
|
||||||
) &&
|
|
||||||
matchesFormRequest(error.config, requestId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -1,284 +1,4 @@
|
|||||||
export const en = {
|
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',
|
|
||||||
'auth.signOut': 'Sign out',
|
|
||||||
'auth.signingOut': 'Signing out…',
|
|
||||||
'auth.password.show': 'Show password',
|
|
||||||
'auth.password.hide': 'Hide password',
|
|
||||||
'auth.login.title': 'Welcome back',
|
|
||||||
'auth.login.description': 'Sign in to continue to your workspace.',
|
|
||||||
'auth.login.email': 'Email address',
|
|
||||||
'auth.login.emailPlaceholder': 'you@example.com',
|
|
||||||
'auth.login.password': 'Password',
|
|
||||||
'auth.login.remember': 'Remember me',
|
|
||||||
'auth.login.forgot': 'Forgot password?',
|
|
||||||
'auth.login.submit': 'Sign in',
|
|
||||||
'auth.login.pending': 'Signing in…',
|
|
||||||
'auth.login.help': 'Need an account? Contact your workspace administrator.',
|
|
||||||
'auth.login.statusTitle': 'Account update',
|
|
||||||
'auth.forgot.title': 'Reset your password',
|
|
||||||
'auth.forgot.description':
|
|
||||||
'Enter your account email and we will send you a secure reset link.',
|
|
||||||
'auth.forgot.email': 'Email address',
|
|
||||||
'auth.forgot.emailPlaceholder': 'you@example.com',
|
|
||||||
'auth.forgot.emailHint':
|
|
||||||
'Use the email address associated with your KanbanGit account.',
|
|
||||||
'auth.forgot.submit': 'Send reset link',
|
|
||||||
'auth.forgot.pending': 'Sending reset link…',
|
|
||||||
'auth.forgot.sentTitle': 'Reset link sent',
|
|
||||||
'auth.reset.title': 'Choose a new password',
|
|
||||||
'auth.reset.description':
|
|
||||||
'Create a new password for the account linked below.',
|
|
||||||
'auth.reset.email': 'Account email',
|
|
||||||
'auth.reset.password': 'New password',
|
|
||||||
'auth.reset.passwordHint':
|
|
||||||
'Use a password you do not use for another account.',
|
|
||||||
'auth.reset.passwordConfirmation': 'Confirm new password',
|
|
||||||
'auth.reset.submit': 'Save new password',
|
|
||||||
'auth.reset.pending': 'Updating your password…',
|
|
||||||
'auth.reset.invalidTitle': 'This reset link is no longer valid',
|
|
||||||
'auth.reset.requestNew': 'Request a new reset link',
|
|
||||||
'auth.reset.footer':
|
|
||||||
'For your security, reset links expire and can only be used once.',
|
|
||||||
'auth.confirm.title': 'Confirm it is you',
|
|
||||||
'auth.confirm.description':
|
|
||||||
'This action affects sensitive account information. Enter your password to continue.',
|
|
||||||
'auth.confirm.password': 'Current password',
|
|
||||||
'auth.confirm.passwordHint':
|
|
||||||
'Your password is checked securely and is never saved in a draft.',
|
|
||||||
'auth.confirm.submit': 'Confirm and continue',
|
|
||||||
'auth.confirm.pending': 'Confirming your password…',
|
|
||||||
'auth.confirm.footer':
|
|
||||||
'Password confirmation expires automatically after a short time.',
|
|
||||||
'auth.forceChange.title': 'Secure your account',
|
|
||||||
'auth.forceChange.description':
|
|
||||||
'You need to replace your temporary password before continuing to KanbanGit.',
|
|
||||||
'auth.forceChange.currentPassword': 'Temporary password',
|
|
||||||
'auth.forceChange.password': 'New password',
|
|
||||||
'auth.forceChange.passwordHint':
|
|
||||||
'Choose a password that differs from your temporary password.',
|
|
||||||
'auth.forceChange.passwordConfirmation': 'Confirm new password',
|
|
||||||
'auth.forceChange.submit': 'Save password and continue',
|
|
||||||
'auth.forceChange.pending': 'Securing your account…',
|
|
||||||
'auth.forceChange.statusTitle': 'Password update',
|
|
||||||
'auth.forceChange.wrongAccount': 'Not the account you expected?',
|
|
||||||
'auth.forceChange.footer':
|
|
||||||
'Your password entries are kept in memory only and are cleared when you leave.',
|
|
||||||
'auth.verify.title': 'Check your inbox',
|
|
||||||
'auth.verify.description':
|
|
||||||
'Open the verification link in your email to finish confirming your account.',
|
|
||||||
'auth.verify.emailLabel': 'Verification email address',
|
|
||||||
'auth.verify.resend': 'Resend verification email',
|
|
||||||
'auth.verify.pending': 'Sending verification email…',
|
|
||||||
'auth.verify.sentTitle': 'Verification link sent',
|
|
||||||
'auth.verify.sentDescription':
|
|
||||||
'Check your inbox for a new link. If it does not arrive, check your spam folder.',
|
|
||||||
'auth.verify.help': 'Already verified in another tab?',
|
|
||||||
'auth.verify.continue': 'Continue to workspace',
|
|
||||||
'auth.verify.wrongAccount':
|
|
||||||
'Wrong account? Sign out and use a different email address.',
|
|
||||||
'auth.twoFactor.title': 'Two-step verification',
|
|
||||||
'auth.twoFactor.description':
|
|
||||||
'Enter the six-digit code from your authenticator app to finish signing in.',
|
|
||||||
'auth.twoFactor.code': 'Authentication code',
|
|
||||||
'auth.twoFactor.codeHint':
|
|
||||||
'Enter all six digits, including any leading zero.',
|
|
||||||
'auth.twoFactor.recoveryTitle': 'Use a recovery code',
|
|
||||||
'auth.twoFactor.recoveryDescription':
|
|
||||||
'Enter one of the recovery codes you saved when you set up two-factor authentication.',
|
|
||||||
'auth.twoFactor.recoveryCode': 'Recovery code',
|
|
||||||
'auth.twoFactor.recoveryHint': 'Each recovery code can only be used once.',
|
|
||||||
'auth.twoFactor.useRecovery': 'Use a recovery code instead',
|
|
||||||
'auth.twoFactor.useCode': 'Use an authenticator code instead',
|
|
||||||
'auth.twoFactor.submit': 'Verify and sign in',
|
|
||||||
'auth.twoFactor.pending': 'Verifying your code…',
|
|
||||||
'auth.feedback.pending': 'Please wait…',
|
|
||||||
'auth.feedback.completed': 'Request completed',
|
|
||||||
'auth.feedback.validationTitle': 'Check your details',
|
|
||||||
'auth.feedback.validationDescription':
|
|
||||||
'Review the message below and try again. Your entries have been kept.',
|
|
||||||
'auth.feedback.expiredTitle': 'Your session has expired',
|
|
||||||
'auth.feedback.expiredDescription':
|
|
||||||
'Return to sign in to start a new session. You will need to enter your details again.',
|
|
||||||
'auth.feedback.limitedTitle': 'Too many attempts',
|
|
||||||
'auth.feedback.limitedDescription':
|
|
||||||
'Please wait a moment before trying again.',
|
|
||||||
'auth.feedback.networkDescription':
|
|
||||||
'We could not confirm the result. Your entries are still here. Check your connection before trying again.',
|
|
||||||
'auth.feedback.serverDescription':
|
|
||||||
'The server returned an unexpected response. Your entries are still here. Please try again in a moment.',
|
|
||||||
'auth.feedback.cancelledDescription':
|
|
||||||
'The request was interrupted. Your entries are still here if you want to try again.',
|
|
||||||
'navigation.main': 'Main navigation',
|
'navigation.main': 'Main navigation',
|
||||||
'navigation.workspace': 'Workspace',
|
'navigation.workspace': 'Workspace',
|
||||||
'navigation.project': 'Project navigation',
|
'navigation.project': 'Project navigation',
|
||||||
@@ -323,39 +43,6 @@ export const en = {
|
|||||||
'appearance.dark': 'Dark',
|
'appearance.dark': 'Dark',
|
||||||
'appearance.system': 'System',
|
'appearance.system': 'System',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.close': 'Close',
|
|
||||||
'common.retry': 'Try again',
|
|
||||||
'feedback.empty': 'Nothing here yet',
|
|
||||||
'feedback.saved': 'Changes saved',
|
|
||||||
'forms.saving': 'Saving changes…',
|
|
||||||
'forms.leaveConfirm': 'You have unsaved changes. Leave without saving?',
|
|
||||||
'forms.validationTitle': 'Check your entries',
|
|
||||||
'forms.validationDescription':
|
|
||||||
'The server rejected some fields. Your entries are still here. Correct the highlighted errors and save again.',
|
|
||||||
'forms.networkTitle': 'Connection interrupted',
|
|
||||||
'forms.networkDescription':
|
|
||||||
'Your entries are still here, but we could not confirm whether the server saved them. Check your connection and verify the result before trying again.',
|
|
||||||
'forms.serverTitle': 'The request could not be completed',
|
|
||||||
'forms.serverDescription':
|
|
||||||
'Your entries are still here. The server returned an unexpected response; your session may have expired. Verify the result before trying again.',
|
|
||||||
'forms.cancelledTitle': 'Saving was interrupted',
|
|
||||||
'forms.cancelledDescription':
|
|
||||||
'Your entries are still here. Check whether the changes were saved before trying again.',
|
|
||||||
'tickets.noBranches': 'No matching branches',
|
|
||||||
'tickets.noBranchesDescription':
|
|
||||||
'No matching branches were found across the linked repositories.',
|
|
||||||
'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.save': 'Save',
|
||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.error': 'Something went wrong.',
|
'common.error': 'Something went wrong.',
|
||||||
|
|||||||
@@ -1,211 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import {
|
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { dashboard } from '@/routes/index';
|
import { dashboard } from '@/routes/index';
|
||||||
import { index as projectsIndex, show as showProject } from '@/routes/projects';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
|
|
||||||
|
|
||||||
const props = defineProps<DashboardPageProps>();
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
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(() => [
|
|
||||||
{
|
{
|
||||||
label: t('dashboard.summary.assignedTickets'),
|
title: 'Dashboard',
|
||||||
value: assignedTickets.value.length,
|
href: dashboard(),
|
||||||
icon: ListTodo,
|
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="t('navigation.dashboard')" />
|
<div
|
||||||
|
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||||
<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">
|
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||||
<CardContent
|
<div
|
||||||
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<PlaceholderPattern />
|
||||||
<p
|
|
||||||
class="text-caption text-muted-foreground break-words"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</p>
|
|
||||||
<p class="text-title mt-1 tabular-nums">
|
|
||||||
{{ item.value }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div
|
||||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||||
>
|
>
|
||||||
<component
|
<PlaceholderPattern />
|
||||||
:is="item.icon"
|
</div>
|
||||||
class="size-5"
|
<div
|
||||||
aria-hidden="true"
|
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||||
/>
|
|
||||||
</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">
|
<PlaceholderPattern />
|
||||||
<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')"
|
|
||||||
>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div
|
||||||
as-child
|
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
||||||
variant="link"
|
|
||||||
class="self-start sm:self-auto"
|
|
||||||
>
|
>
|
||||||
<Link
|
<PlaceholderPattern />
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,77 +1,52 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
|
||||||
import { onUnmounted } from 'vue';
|
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
|
||||||
import { store } from '@/routes/password/confirm';
|
import { store } from '@/routes/password/confirm';
|
||||||
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const form = useForm({ password: '' }).dontRemember('password');
|
|
||||||
const protection = useFormProtection(form, { isDirty: () => false });
|
|
||||||
const submit = (): void =>
|
|
||||||
protection.submit(store(), { onSuccess: () => form.reset() });
|
|
||||||
onUnmounted(() => form.reset());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthLayout
|
||||||
:title="t('auth.confirm.title')"
|
title="Confirm your password"
|
||||||
:description="t('auth.confirm.description')"
|
description="This is a secure area of the application. Please confirm your password before continuing."
|
||||||
:icon="ShieldCheck"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.confirm.title')" />
|
<Head title="Confirm password" />
|
||||||
<form
|
|
||||||
:action="store.url()"
|
<Form
|
||||||
method="post"
|
v-bind="store.form()"
|
||||||
class="grid gap-5"
|
reset-on-success
|
||||||
@submit.prevent="submit"
|
v-slot="{ errors, processing }"
|
||||||
>
|
>
|
||||||
<AuthFormFeedback
|
<div class="space-y-6">
|
||||||
:state="protection.state.value"
|
<div class="grid gap-2">
|
||||||
:response-status="protection.responseStatus.value"
|
<Label htmlFor="password">Password</Label>
|
||||||
:loading-title="t('auth.confirm.pending')"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="password">{{ t('auth.confirm.password') }}</Label>
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
v-model="form.password"
|
|
||||||
name="password"
|
name="password"
|
||||||
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="form.processing"
|
|
||||||
:invalid="Boolean(form.errors.password)"
|
|
||||||
aria-describedby="password-hint password-error"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
id="password-hint"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ t('auth.confirm.passwordHint') }}
|
|
||||||
</p>
|
|
||||||
<InputError
|
|
||||||
id="password-error"
|
|
||||||
:message="form.errors.password"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputError :message="errors.password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:loading="form.processing"
|
:disabled="processing"
|
||||||
:loading-label="t('auth.confirm.pending')"
|
|
||||||
data-test="confirm-password-button"
|
data-test="confirm-password-button"
|
||||||
>{{ t('auth.confirm.submit') }}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
<Spinner v-if="processing" />
|
||||||
<template #footer>{{ t('auth.confirm.footer') }}</template>
|
Confirm password
|
||||||
</AuthFlowLayout>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AuthLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,170 +1,115 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import { KeyRound } from 'lucide-vue-next';
|
import { LogOut } from 'lucide-vue-next';
|
||||||
import { computed, onUnmounted } from 'vue';
|
|
||||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
import { logout } from '@/routes';
|
||||||
import { logout } from '@/routes/index';
|
|
||||||
|
|
||||||
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
|
withDefaults(
|
||||||
const { t } = useLocalization();
|
defineProps<{
|
||||||
const form = useForm({
|
status?: string | null;
|
||||||
current_password: '',
|
}>(),
|
||||||
password: '',
|
{
|
||||||
password_confirmation: '',
|
status: null,
|
||||||
}).dontRemember('current_password', 'password', 'password_confirmation');
|
},
|
||||||
const logoutForm = useForm({});
|
);
|
||||||
const protection = useFormProtection(form, { isDirty: () => false });
|
|
||||||
const logoutProtection = useFormProtection(logoutForm);
|
|
||||||
const busy = computed(() => form.processing || logoutForm.processing);
|
|
||||||
const submit = (): void =>
|
|
||||||
protection.submit(SecurityController.update(), {
|
|
||||||
onSuccess: () => form.reset(),
|
|
||||||
});
|
|
||||||
const signOut = (): void => {
|
|
||||||
if (busy.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.reset();
|
|
||||||
logoutProtection.submit(logout());
|
|
||||||
};
|
|
||||||
onUnmounted(() => form.reset());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthLayout
|
||||||
:title="t('auth.forceChange.title')"
|
title="Change your password"
|
||||||
:description="t('auth.forceChange.description')"
|
description="You must update your password before you can continue using the application."
|
||||||
:icon="KeyRound"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.forceChange.title')" />
|
<Head title="Change your password" />
|
||||||
<FeedbackState
|
|
||||||
|
<div class="mb-6 flex justify-end">
|
||||||
|
<Button as-child variant="ghost" size="sm">
|
||||||
|
<Link :href="logout()" as="button">
|
||||||
|
<LogOut class="size-4" />
|
||||||
|
Log out
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
v-if="status"
|
v-if="status"
|
||||||
state="success"
|
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
|
||||||
:title="t('auth.forceChange.statusTitle')"
|
|
||||||
:description="status"
|
|
||||||
/>
|
|
||||||
<form
|
|
||||||
:action="SecurityController.update.url()"
|
|
||||||
method="post"
|
|
||||||
class="grid gap-5"
|
|
||||||
@submit.prevent="submit"
|
|
||||||
>
|
>
|
||||||
<AuthFormFeedback
|
{{ status }}
|
||||||
:state="protection.state.value"
|
</div>
|
||||||
:response-status="protection.responseStatus.value"
|
|
||||||
:loading-title="t('auth.forceChange.pending')"
|
<Form
|
||||||
@retry="submit"
|
v-bind="SecurityController.update.form()"
|
||||||
/>
|
reset-on-success
|
||||||
<div class="gap-stack grid">
|
:reset-on-error="[
|
||||||
<Label for="current_password">{{
|
'password',
|
||||||
t('auth.forceChange.currentPassword')
|
'password_confirmation',
|
||||||
}}</Label>
|
'current_password',
|
||||||
|
]"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label htmlFor="current_password">Current password</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="current_password"
|
id="current_password"
|
||||||
v-model="form.current_password"
|
|
||||||
name="current_password"
|
name="current_password"
|
||||||
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="busy"
|
placeholder="Current password"
|
||||||
:invalid="Boolean(form.errors.current_password)"
|
|
||||||
aria-describedby="current-password-error"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="current-password-error"
|
|
||||||
:message="form.errors.current_password"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputError :message="errors.current_password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="password">{{
|
<div class="grid gap-2">
|
||||||
t('auth.forceChange.password')
|
<Label htmlFor="password">New password</Label>
|
||||||
}}</Label>
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
v-model="form.password"
|
|
||||||
name="password"
|
name="password"
|
||||||
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
:disabled="busy"
|
placeholder="New password"
|
||||||
:invalid="Boolean(form.errors.password)"
|
|
||||||
aria-describedby="new-password-hint new-password-error"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
id="new-password-hint"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ t('auth.forceChange.passwordHint') }}
|
|
||||||
</p>
|
|
||||||
<InputError
|
|
||||||
id="new-password-error"
|
|
||||||
:message="form.errors.password"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputError :message="errors.password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="password_confirmation">{{
|
<div class="grid gap-2">
|
||||||
t('auth.forceChange.passwordConfirmation')
|
<Label htmlFor="password_confirmation">
|
||||||
}}</Label>
|
Confirm new password
|
||||||
|
</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
v-model="form.password_confirmation"
|
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
|
class="mt-1 block w-full"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
:disabled="busy"
|
placeholder="Confirm new password"
|
||||||
:invalid="Boolean(form.errors.password_confirmation)"
|
|
||||||
aria-describedby="new-password-confirmation-error"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="new-password-confirmation-error"
|
|
||||||
:message="form.errors.password_confirmation"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputError :message="errors.password_confirmation" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="logoutForm.processing"
|
:disabled="processing"
|
||||||
:loading="form.processing"
|
|
||||||
:loading-label="t('auth.forceChange.pending')"
|
|
||||||
data-test="force-password-change-button"
|
data-test="force-password-change-button"
|
||||||
>{{ t('auth.forceChange.submit') }}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
<Spinner v-if="processing" />
|
||||||
<div class="gap-stack pt-column grid border-t">
|
Save new password
|
||||||
<p class="text-body text-muted-foreground">
|
</Button>
|
||||||
{{ t('auth.forceChange.wrongAccount') }}
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
:action="logout.url()"
|
|
||||||
method="post"
|
|
||||||
class="gap-card grid"
|
|
||||||
@submit.prevent="signOut"
|
|
||||||
>
|
|
||||||
<AuthFormFeedback
|
|
||||||
:state="logoutProtection.state.value"
|
|
||||||
:response-status="logoutProtection.responseStatus.value"
|
|
||||||
:loading-title="t('auth.signingOut')"
|
|
||||||
@retry="signOut"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="form.processing"
|
|
||||||
:loading="logoutForm.processing"
|
|
||||||
:loading-label="t('auth.signingOut')"
|
|
||||||
>{{ t('auth.signOut') }}</Button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>{{ t('auth.forceChange.footer') }}</template>
|
</div>
|
||||||
</AuthFlowLayout>
|
</Form>
|
||||||
|
</AuthLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,89 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { Mail } from 'lucide-vue-next';
|
|
||||||
import { onUnmounted } from 'vue';
|
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
import TextLink from '@/components/TextLink.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
|
||||||
import { login } from '@/routes/index';
|
import { login } from '@/routes/index';
|
||||||
import { email } from '@/routes/password';
|
import { email } from '@/routes/password';
|
||||||
|
|
||||||
defineProps<{ status?: string }>();
|
defineProps<{
|
||||||
const { t } = useLocalization();
|
status?: string;
|
||||||
const form = useForm({ email: '' }).dontRemember('email');
|
}>();
|
||||||
const protection = useFormProtection(form, { isDirty: () => false });
|
|
||||||
const submit = (): void => protection.submit(email());
|
|
||||||
onUnmounted(() => form.reset());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthLayout
|
||||||
:title="t('auth.forgot.title')"
|
title="Forgot password"
|
||||||
:description="t('auth.forgot.description')"
|
description="Enter your email to receive a password reset link"
|
||||||
:icon="Mail"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.forgot.title')" />
|
<Head title="Forgot password" />
|
||||||
<FeedbackState
|
|
||||||
|
<div
|
||||||
v-if="status"
|
v-if="status"
|
||||||
state="success"
|
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||||
:title="t('auth.forgot.sentTitle')"
|
|
||||||
:description="status"
|
|
||||||
/>
|
|
||||||
<form
|
|
||||||
:action="email.url()"
|
|
||||||
method="post"
|
|
||||||
class="grid gap-5"
|
|
||||||
@submit.prevent="submit"
|
|
||||||
>
|
>
|
||||||
<AuthFormFeedback
|
{{ status }}
|
||||||
:state="protection.state.value"
|
</div>
|
||||||
:response-status="protection.responseStatus.value"
|
|
||||||
:loading-title="t('auth.forgot.pending')"
|
<div class="space-y-6">
|
||||||
@retry="submit"
|
<Form v-bind="email.form()" v-slot="{ errors, processing }">
|
||||||
/>
|
<div class="grid gap-2">
|
||||||
<div class="gap-stack grid">
|
<Label for="email">Email address</Label>
|
||||||
<Label for="email">{{ t('auth.forgot.email') }}</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
v-model="form.email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
type="email"
|
||||||
inputmode="email"
|
name="email"
|
||||||
autocomplete="email"
|
autocomplete="off"
|
||||||
autocapitalize="none"
|
|
||||||
:spellcheck="false"
|
|
||||||
required
|
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="form.processing"
|
placeholder="email@example.com"
|
||||||
:placeholder="t('auth.forgot.emailPlaceholder')"
|
|
||||||
:invalid="Boolean(form.errors.email)"
|
|
||||||
aria-describedby="email-hint email-error"
|
|
||||||
/>
|
/>
|
||||||
<p id="email-hint" class="text-caption text-muted-foreground">
|
<InputError :message="errors.email" />
|
||||||
{{ t('auth.forgot.emailHint') }}
|
|
||||||
</p>
|
|
||||||
<InputError id="email-error" :message="form.errors.email" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="my-6 flex items-center justify-start">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:loading="form.processing"
|
:disabled="processing"
|
||||||
:loading-label="t('auth.forgot.pending')"
|
|
||||||
data-test="email-password-reset-link-button"
|
data-test="email-password-reset-link-button"
|
||||||
>{{ t('auth.forgot.submit') }}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
<Spinner v-if="processing" />
|
||||||
<template #footer
|
Email password reset link
|
||||||
><TextLink :href="login()">{{
|
</Button>
|
||||||
t('auth.backToLogin')
|
</div>
|
||||||
}}</TextLink></template
|
</Form>
|
||||||
>
|
|
||||||
</AuthFlowLayout>
|
<div class="text-muted-foreground space-x-1 text-center text-sm">
|
||||||
|
<span>Or, return to</span>
|
||||||
|
<TextLink :href="login()">log in</TextLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { LogIn } from 'lucide-vue-next';
|
|
||||||
import { onUnmounted } from 'vue';
|
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
import TextLink from '@/components/TextLink.vue';
|
||||||
@@ -11,114 +7,94 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
|
||||||
import { store } from '@/routes/login';
|
import { store } from '@/routes/login';
|
||||||
import { request } from '@/routes/password';
|
import { request } from '@/routes/password';
|
||||||
|
|
||||||
defineProps<{ status?: string; canResetPassword: boolean }>();
|
defineProps<{
|
||||||
const { t } = useLocalization();
|
status?: string;
|
||||||
// Credentials stay in memory only; no remembered form key or browser storage.
|
canResetPassword: boolean;
|
||||||
const form = useForm({ email: '', password: '', remember: false }).dontRemember(
|
}>();
|
||||||
'password',
|
|
||||||
);
|
|
||||||
const protection = useFormProtection(form, { isDirty: () => false });
|
|
||||||
const submit = (): void =>
|
|
||||||
protection.submit(store(), {
|
|
||||||
onSuccess: () => form.reset('password'),
|
|
||||||
});
|
|
||||||
onUnmounted(() => form.reset('password'));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthBase
|
||||||
:title="t('auth.login.title')"
|
title="Log in to your account"
|
||||||
:description="t('auth.login.description')"
|
description="Enter your email and password below to log in"
|
||||||
:icon="LogIn"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.login.submit')" />
|
<Head title="Log in" />
|
||||||
<FeedbackState
|
|
||||||
|
<div
|
||||||
v-if="status"
|
v-if="status"
|
||||||
state="success"
|
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||||
:title="t('auth.login.statusTitle')"
|
|
||||||
:description="status"
|
|
||||||
/>
|
|
||||||
<form
|
|
||||||
:action="store.url()"
|
|
||||||
method="post"
|
|
||||||
class="grid gap-5"
|
|
||||||
@submit.prevent="submit"
|
|
||||||
>
|
>
|
||||||
<AuthFormFeedback
|
{{ status }}
|
||||||
:state="protection.state.value"
|
</div>
|
||||||
:response-status="protection.responseStatus.value"
|
|
||||||
:loading-title="t('auth.login.pending')"
|
<Form
|
||||||
@retry="submit"
|
v-bind="store.form()"
|
||||||
/>
|
:reset-on-success="['password']"
|
||||||
<div class="gap-stack grid">
|
v-slot="{ errors, processing }"
|
||||||
<Label for="email">{{ t('auth.login.email') }}</Label>
|
class="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="email">Email address</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
v-model="form.email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
type="email"
|
||||||
inputmode="email"
|
name="email"
|
||||||
autocomplete="username"
|
|
||||||
autocapitalize="none"
|
|
||||||
:spellcheck="false"
|
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="form.processing"
|
:tabindex="1"
|
||||||
:placeholder="t('auth.login.emailPlaceholder')"
|
autocomplete="email"
|
||||||
:invalid="Boolean(form.errors.email)"
|
placeholder="email@example.com"
|
||||||
aria-describedby="email-error"
|
|
||||||
/>
|
/>
|
||||||
<InputError id="email-error" :message="form.errors.email" />
|
<InputError :message="errors.email" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="password">{{ t('auth.login.password') }}</Label>
|
<div class="grid gap-2">
|
||||||
<PasswordInput
|
<div class="flex items-center justify-between">
|
||||||
id="password"
|
<Label for="password">Password</Label>
|
||||||
v-model="form.password"
|
|
||||||
name="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
:disabled="form.processing"
|
|
||||||
:invalid="Boolean(form.errors.password)"
|
|
||||||
aria-describedby="password-error"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="password-error"
|
|
||||||
:message="form.errors.password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="gap-card flex flex-wrap items-center justify-between">
|
|
||||||
<Label for="remember" class="min-h-control gap-stack">
|
|
||||||
<Checkbox
|
|
||||||
id="remember"
|
|
||||||
v-model="form.remember"
|
|
||||||
name="remember"
|
|
||||||
:disabled="form.processing"
|
|
||||||
/>
|
|
||||||
{{ t('auth.login.remember') }}
|
|
||||||
</Label>
|
|
||||||
<TextLink
|
<TextLink
|
||||||
v-if="canResetPassword"
|
v-if="canResetPassword"
|
||||||
:href="request()"
|
:href="request()"
|
||||||
class="min-h-control text-body inline-flex items-center"
|
class="text-sm"
|
||||||
>{{ t('auth.login.forgot') }}</TextLink
|
:tabindex="5"
|
||||||
>
|
>
|
||||||
|
Forgot password?
|
||||||
|
</TextLink>
|
||||||
</div>
|
</div>
|
||||||
|
<PasswordInput
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
:tabindex="2"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="Password"
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="remember" class="flex items-center space-x-3">
|
||||||
|
<Checkbox id="remember" name="remember" :tabindex="3" />
|
||||||
|
<span>Remember me</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full"
|
class="mt-4 w-full"
|
||||||
:loading="form.processing"
|
:tabindex="4"
|
||||||
:loading-label="t('auth.login.pending')"
|
:disabled="processing"
|
||||||
data-test="login-button"
|
data-test="login-button"
|
||||||
>{{ t('auth.login.submit') }}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
<Spinner v-if="processing" />
|
||||||
<template #footer>{{ t('auth.login.help') }}</template>
|
Log in
|
||||||
</AuthFlowLayout>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AuthBase>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,136 +1,88 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { KeyRound } from 'lucide-vue-next';
|
import { ref } from 'vue';
|
||||||
import { computed, onUnmounted } from 'vue';
|
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
import { update } from '@/routes/password';
|
||||||
import { request, update } from '@/routes/password';
|
|
||||||
|
|
||||||
const props = defineProps<{ token: string; email: string }>();
|
const props = defineProps<{
|
||||||
const { t } = useLocalization();
|
token: string;
|
||||||
const form = useForm({
|
email: string;
|
||||||
token: props.token,
|
}>();
|
||||||
email: props.email,
|
|
||||||
password: '',
|
const inputEmail = ref(props.email);
|
||||||
password_confirmation: '',
|
|
||||||
}).dontRemember('token', 'email', 'password', 'password_confirmation');
|
|
||||||
const protection = useFormProtection(form, { isDirty: () => false });
|
|
||||||
const invalidLink = computed(() => Boolean(form.errors.email));
|
|
||||||
const feedbackState = computed(() =>
|
|
||||||
invalidLink.value ? 'idle' : protection.state.value,
|
|
||||||
);
|
|
||||||
const submit = (): void =>
|
|
||||||
protection.submit(update(), {
|
|
||||||
onSuccess: () => form.reset('password', 'password_confirmation'),
|
|
||||||
});
|
|
||||||
onUnmounted(() => form.reset());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthLayout
|
||||||
:title="t('auth.reset.title')"
|
title="Reset password"
|
||||||
:description="t('auth.reset.description')"
|
description="Please enter your new password below"
|
||||||
:icon="KeyRound"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.reset.title')" />
|
<Head title="Reset password" />
|
||||||
<FeedbackState
|
|
||||||
v-if="invalidLink"
|
<Form
|
||||||
state="error"
|
v-bind="update.form()"
|
||||||
:title="t('auth.reset.invalidTitle')"
|
:transform="(data) => ({ ...data, token, email })"
|
||||||
:description="form.errors.email"
|
:reset-on-success="['password', 'password_confirmation']"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
>
|
>
|
||||||
<Button as-child variant="outline" class="w-full">
|
<div class="grid gap-6">
|
||||||
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
|
<div class="grid gap-2">
|
||||||
</Button>
|
<Label for="email">Email</Label>
|
||||||
</FeedbackState>
|
|
||||||
<form
|
|
||||||
:action="update.url()"
|
|
||||||
method="post"
|
|
||||||
class="grid gap-5"
|
|
||||||
@submit.prevent="submit"
|
|
||||||
>
|
|
||||||
<AuthFormFeedback
|
|
||||||
:state="feedbackState"
|
|
||||||
:response-status="protection.responseStatus.value"
|
|
||||||
:loading-title="t('auth.reset.pending')"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="token" :value="form.token" />
|
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="email">{{ t('auth.reset.email') }}</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
v-model="form.email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
type="email"
|
||||||
autocomplete="username"
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
v-model="inputEmail"
|
||||||
|
class="mt-1 block w-full"
|
||||||
readonly
|
readonly
|
||||||
:disabled="form.processing"
|
|
||||||
:invalid="Boolean(form.errors.email)"
|
|
||||||
aria-describedby="email-error"
|
|
||||||
/>
|
/>
|
||||||
<InputError id="email-error" :message="form.errors.email" />
|
<InputError :message="errors.email" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="password">{{ t('auth.reset.password') }}</Label>
|
<div class="grid gap-2">
|
||||||
|
<Label for="password">Password</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
v-model="form.password"
|
|
||||||
name="password"
|
name="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
class="mt-1 block w-full"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="form.processing"
|
placeholder="Password"
|
||||||
:invalid="Boolean(form.errors.password)"
|
|
||||||
aria-describedby="password-hint password-error"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
id="password-hint"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ t('auth.reset.passwordHint') }}
|
|
||||||
</p>
|
|
||||||
<InputError
|
|
||||||
id="password-error"
|
|
||||||
:message="form.errors.password"
|
|
||||||
/>
|
/>
|
||||||
|
<InputError :message="errors.password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="password_confirmation">{{
|
<div class="grid gap-2">
|
||||||
t('auth.reset.passwordConfirmation')
|
<Label for="password_confirmation">
|
||||||
}}</Label>
|
Confirm password
|
||||||
|
</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
v-model="form.password_confirmation"
|
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
required
|
class="mt-1 block w-full"
|
||||||
:disabled="form.processing"
|
placeholder="Confirm password"
|
||||||
:invalid="Boolean(form.errors.password_confirmation)"
|
|
||||||
aria-describedby="password-confirmation-error"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="password-confirmation-error"
|
|
||||||
:message="form.errors.password_confirmation"
|
|
||||||
/>
|
/>
|
||||||
|
<InputError :message="errors.password_confirmation" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full"
|
class="mt-4 w-full"
|
||||||
:loading="form.processing"
|
:disabled="processing"
|
||||||
:loading-label="t('auth.reset.pending')"
|
|
||||||
data-test="reset-password-button"
|
data-test="reset-password-button"
|
||||||
>{{ t('auth.reset.submit') }}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
<Spinner v-if="processing" />
|
||||||
<template #footer>{{ t('auth.reset.footer') }}</template>
|
Reset password
|
||||||
</AuthFlowLayout>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</AuthLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,173 +1,133 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, useForm } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { KeyRound, ShieldCheck } from 'lucide-vue-next';
|
import { computed, ref } from 'vue';
|
||||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
|
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import TextLink from '@/components/TextLink.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import {
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
InputOTP,
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
InputOTPGroup,
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
InputOTPSlot,
|
||||||
import { login } from '@/routes/index';
|
} from '@/components/ui/input-otp';
|
||||||
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import { store } from '@/routes/two-factor/login';
|
import { store } from '@/routes/two-factor/login';
|
||||||
|
import type { TwoFactorConfigContent } from '@/types';
|
||||||
|
|
||||||
const { t } = useLocalization();
|
const authConfigContent = computed<TwoFactorConfigContent>(() => {
|
||||||
const recovery = ref(false);
|
if (showRecoveryInput.value) {
|
||||||
const form = useForm({ code: '', recovery_code: '' }).dontRemember(
|
return {
|
||||||
'code',
|
title: 'Recovery code',
|
||||||
'recovery_code',
|
description:
|
||||||
);
|
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||||
const protection = useFormProtection(form, { isDirty: () => false });
|
buttonText: 'login using an authentication code',
|
||||||
const codeInput = useTemplateRef('codeInput');
|
};
|
||||||
const recoveryInput = useTemplateRef('recoveryInput');
|
|
||||||
const fieldError = computed(() =>
|
|
||||||
recovery.value
|
|
||||||
? (form.errors.recovery_code ?? form.errors.code)
|
|
||||||
: form.errors.code,
|
|
||||||
);
|
|
||||||
const toggleMode = async (): Promise<void> => {
|
|
||||||
if (form.processing) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
recovery.value = !recovery.value;
|
|
||||||
form.resetAndClearErrors();
|
return {
|
||||||
protection.clearFeedback();
|
title: 'Authentication code',
|
||||||
await nextTick();
|
description:
|
||||||
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
|
'Enter the authentication code provided by your authenticator application.',
|
||||||
|
buttonText: 'login using a recovery code',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const showRecoveryInput = ref<boolean>(false);
|
||||||
|
|
||||||
|
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
||||||
|
showRecoveryInput.value = !showRecoveryInput.value;
|
||||||
|
clearErrors();
|
||||||
|
code.value = '';
|
||||||
};
|
};
|
||||||
const pasteCode = (event: ClipboardEvent): void => {
|
|
||||||
const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
|
const code = ref<string>('');
|
||||||
if (/^[0-9]{6}$/.test(code)) {
|
|
||||||
event.preventDefault();
|
|
||||||
form.code = code;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const submit = (): void => {
|
|
||||||
form.transform((data) =>
|
|
||||||
recovery.value
|
|
||||||
? { recovery_code: data.recovery_code.trim() }
|
|
||||||
: { code: data.code },
|
|
||||||
);
|
|
||||||
protection.submit(store(), { onSuccess: () => form.reset() });
|
|
||||||
};
|
|
||||||
onUnmounted(() => form.reset());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthLayout
|
||||||
:title="
|
:title="authConfigContent.title"
|
||||||
t(
|
:description="authConfigContent.description"
|
||||||
recovery
|
|
||||||
? 'auth.twoFactor.recoveryTitle'
|
|
||||||
: 'auth.twoFactor.title',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:description="
|
|
||||||
t(
|
|
||||||
recovery
|
|
||||||
? 'auth.twoFactor.recoveryDescription'
|
|
||||||
: 'auth.twoFactor.description',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:icon="recovery ? KeyRound : ShieldCheck"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.twoFactor.title')" />
|
<Head title="Two-factor authentication" />
|
||||||
<form
|
|
||||||
:action="store.url()"
|
<div class="space-y-6">
|
||||||
method="post"
|
<template v-if="!showRecoveryInput">
|
||||||
class="grid gap-5"
|
<Form
|
||||||
@submit.prevent="submit"
|
v-bind="store.form()"
|
||||||
|
class="space-y-4"
|
||||||
|
reset-on-error
|
||||||
|
@error="code = ''"
|
||||||
|
#default="{ errors, processing, clearErrors }"
|
||||||
>
|
>
|
||||||
<AuthFormFeedback
|
<input type="hidden" name="code" :value="code" />
|
||||||
:state="protection.state.value"
|
<div
|
||||||
:response-status="protection.responseStatus.value"
|
class="flex flex-col items-center justify-center space-y-3 text-center"
|
||||||
:loading-title="t('auth.twoFactor.pending')"
|
>
|
||||||
@retry="submit"
|
<div class="flex w-full items-center justify-center">
|
||||||
/>
|
<InputOTP
|
||||||
<div v-if="!recovery" class="gap-stack grid">
|
id="otp"
|
||||||
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
|
v-model="code"
|
||||||
<Input
|
:maxlength="6"
|
||||||
id="code"
|
:disabled="processing"
|
||||||
key="code"
|
|
||||||
ref="codeInput"
|
|
||||||
v-model="form.code"
|
|
||||||
name="code"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
minlength="6"
|
|
||||||
maxlength="6"
|
|
||||||
required
|
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="form.processing"
|
>
|
||||||
:invalid="Boolean(fieldError)"
|
<InputOTPGroup>
|
||||||
aria-describedby="code-hint code-error"
|
<InputOTPSlot
|
||||||
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
|
v-for="index in 6"
|
||||||
@paste="pasteCode"
|
:key="index"
|
||||||
|
:index="index - 1"
|
||||||
/>
|
/>
|
||||||
<p id="code-hint" class="text-caption text-muted-foreground">
|
</InputOTPGroup>
|
||||||
{{ t('auth.twoFactor.codeHint') }}
|
</InputOTP>
|
||||||
</p>
|
|
||||||
<InputError id="code-error" :message="fieldError" />
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="gap-stack grid">
|
<InputError :message="errors.code" />
|
||||||
<Label for="recovery_code">{{
|
</div>
|
||||||
t('auth.twoFactor.recoveryCode')
|
<Button type="submit" class="w-full" :disabled="processing"
|
||||||
}}</Label>
|
>Continue</Button
|
||||||
|
>
|
||||||
|
<div class="text-muted-foreground text-center text-sm">
|
||||||
|
<span>or you can </span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||||
|
@click="() => toggleRecoveryMode(clearErrors)"
|
||||||
|
>
|
||||||
|
{{ authConfigContent.buttonText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Form
|
||||||
|
v-bind="store.form()"
|
||||||
|
class="space-y-4"
|
||||||
|
reset-on-error
|
||||||
|
#default="{ errors, processing, clearErrors }"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="recovery_code"
|
|
||||||
key="recovery"
|
|
||||||
ref="recoveryInput"
|
|
||||||
v-model="form.recovery_code"
|
|
||||||
name="recovery_code"
|
name="recovery_code"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="one-time-code"
|
placeholder="Enter recovery code"
|
||||||
autocapitalize="none"
|
:autofocus="showRecoveryInput"
|
||||||
:spellcheck="false"
|
|
||||||
required
|
required
|
||||||
:disabled="form.processing"
|
|
||||||
:invalid="Boolean(fieldError)"
|
|
||||||
aria-describedby="recovery-hint recovery-error"
|
|
||||||
class="font-mono"
|
|
||||||
/>
|
/>
|
||||||
<p
|
<InputError :message="errors.recovery_code" />
|
||||||
id="recovery-hint"
|
<Button type="submit" class="w-full" :disabled="processing"
|
||||||
class="text-caption text-muted-foreground"
|
>Continue</Button
|
||||||
>
|
>
|
||||||
{{ t('auth.twoFactor.recoveryHint') }}
|
|
||||||
</p>
|
<div class="text-muted-foreground text-center text-sm">
|
||||||
<InputError id="recovery-error" :message="fieldError" />
|
<span>or you can </span>
|
||||||
</div>
|
<button
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
class="w-full"
|
|
||||||
:loading="form.processing"
|
|
||||||
:loading-label="t('auth.twoFactor.pending')"
|
|
||||||
>{{ t('auth.twoFactor.submit') }}</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||||
class="w-full whitespace-normal"
|
@click="() => toggleRecoveryMode(clearErrors)"
|
||||||
:disabled="form.processing"
|
|
||||||
@click="toggleMode"
|
|
||||||
>{{
|
|
||||||
t(
|
|
||||||
recovery
|
|
||||||
? 'auth.twoFactor.useCode'
|
|
||||||
: 'auth.twoFactor.useRecovery',
|
|
||||||
)
|
|
||||||
}}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
{{ authConfigContent.buttonText }}
|
||||||
<template #footer
|
</button>
|
||||||
><TextLink :href="login()">{{
|
</div>
|
||||||
t('auth.backToLogin')
|
</Form>
|
||||||
}}</TextLink></template
|
</template>
|
||||||
>
|
</div>
|
||||||
</AuthFlowLayout>
|
</AuthLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,124 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, router, useForm, usePage } from '@inertiajs/vue3';
|
import { Form, Head } from '@inertiajs/vue3';
|
||||||
import { Mail } from 'lucide-vue-next';
|
import TextLink from '@/components/TextLink.vue';
|
||||||
import { computed } from 'vue';
|
|
||||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
import { logout } from '@/routes/index';
|
||||||
import { dashboard, logout } from '@/routes/index';
|
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
|
|
||||||
const props = defineProps<{ status?: string }>();
|
defineProps<{
|
||||||
const { t } = useLocalization();
|
status?: string;
|
||||||
const page = usePage();
|
}>();
|
||||||
const email = computed(() => page.props.auth.user?.email);
|
|
||||||
const resendForm = useForm({});
|
|
||||||
const logoutForm = useForm({});
|
|
||||||
const resendProtection = useFormProtection(resendForm);
|
|
||||||
const logoutProtection = useFormProtection(logoutForm);
|
|
||||||
const busy = computed(() => resendForm.processing || logoutForm.processing);
|
|
||||||
const sent = computed(
|
|
||||||
() =>
|
|
||||||
resendProtection.state.value === 'success' ||
|
|
||||||
(resendProtection.state.value === 'idle' &&
|
|
||||||
props.status === 'verification-link-sent'),
|
|
||||||
);
|
|
||||||
const resend = (): void => {
|
|
||||||
if (!busy.value) {
|
|
||||||
resendProtection.submit(send(), { preserveScroll: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const signOut = (): void => {
|
|
||||||
if (!busy.value) {
|
|
||||||
logoutProtection.submit(logout());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AuthFlowLayout
|
<AuthLayout
|
||||||
:title="t('auth.verify.title')"
|
title="Verify email"
|
||||||
:description="t('auth.verify.description')"
|
description="Please verify your email address by clicking on the link we just emailed to you."
|
||||||
:icon="Mail"
|
|
||||||
>
|
>
|
||||||
<Head :title="t('auth.verify.title')" />
|
<Head title="Email verification" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="email"
|
v-if="status === 'verification-link-sent'"
|
||||||
class="gap-stack bg-muted p-card grid rounded-md border"
|
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||||
>
|
>
|
||||||
<p class="text-label text-muted-foreground uppercase tracking-wide">
|
A new verification link has been sent to the email address you
|
||||||
{{ t('auth.verify.emailLabel') }}
|
provided during registration.
|
||||||
</p>
|
|
||||||
<p class="text-body break-all font-medium">{{ email }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<FeedbackState
|
|
||||||
v-if="sent"
|
<Form
|
||||||
state="success"
|
v-bind="send.form()"
|
||||||
:title="t('auth.verify.sentTitle')"
|
class="space-y-6 text-center"
|
||||||
:description="t('auth.verify.sentDescription')"
|
v-slot="{ processing }"
|
||||||
/>
|
|
||||||
<form
|
|
||||||
:action="send.url()"
|
|
||||||
method="post"
|
|
||||||
class="gap-column grid"
|
|
||||||
@submit.prevent="resend"
|
|
||||||
>
|
>
|
||||||
<AuthFormFeedback
|
<Button :disabled="processing" variant="secondary">
|
||||||
:state="
|
<Spinner v-if="processing" />
|
||||||
resendProtection.state.value === 'success'
|
Resend verification email
|
||||||
? 'idle'
|
</Button>
|
||||||
: resendProtection.state.value
|
|
||||||
"
|
<TextLink
|
||||||
:response-status="resendProtection.responseStatus.value"
|
:href="logout()"
|
||||||
:loading-title="t('auth.verify.pending')"
|
as="button"
|
||||||
@retry="resend"
|
class="mx-auto block text-sm"
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="busy"
|
|
||||||
:loading="resendForm.processing"
|
|
||||||
:loading-label="t('auth.verify.pending')"
|
|
||||||
>{{ t('auth.verify.resend') }}</Button
|
|
||||||
>
|
>
|
||||||
</form>
|
Log out
|
||||||
<div class="gap-stack pt-column grid border-t">
|
</TextLink>
|
||||||
<p class="text-body text-muted-foreground">
|
</Form>
|
||||||
{{ t('auth.verify.help') }}
|
</AuthLayout>
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="busy"
|
|
||||||
@click="router.visit(dashboard())"
|
|
||||||
>{{ t('auth.verify.continue') }}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
:action="logout.url()"
|
|
||||||
method="post"
|
|
||||||
class="gap-card grid"
|
|
||||||
@submit.prevent="signOut"
|
|
||||||
>
|
|
||||||
<AuthFormFeedback
|
|
||||||
:state="logoutProtection.state.value"
|
|
||||||
:response-status="logoutProtection.responseStatus.value"
|
|
||||||
:loading-title="t('auth.signingOut')"
|
|
||||||
@retry="signOut"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="ghost"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="busy"
|
|
||||||
:loading="logoutForm.processing"
|
|
||||||
:loading-label="t('auth.signingOut')"
|
|
||||||
>{{ t('auth.signOut') }}</Button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
<template #footer>{{ t('auth.verify.wrongAccount') }}</template>
|
|
||||||
</AuthFlowLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import { TicketListItem } from '@/components/tickets';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -13,11 +12,13 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
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 { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-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[] => {
|
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||||
@@ -112,22 +132,94 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
No backlog tickets exist yet.
|
No backlog tickets exist yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="gap-column grid p-4">
|
<div v-else class="overflow-x-auto">
|
||||||
<TicketListItem
|
<table
|
||||||
|
class="divide-border min-w-full divide-y text-sm"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-border divide-y">
|
||||||
|
<tr
|
||||||
v-for="ticket in tickets"
|
v-for="ticket in tickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
:project-id="project.id"
|
class="bg-background"
|
||||||
:ticket="ticket"
|
|
||||||
>
|
>
|
||||||
<template v-if="canEditTickets" #actions>
|
<td class="px-4 py-4">
|
||||||
<Form
|
<Link
|
||||||
v-bind="
|
:href="
|
||||||
ProjectBoardController.move.form({
|
showTicket({
|
||||||
project: project.id,
|
project: project.id,
|
||||||
ticket: ticket.id,
|
ticket: ticket.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="flex w-full flex-col gap-2"
|
>
|
||||||
|
<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 }"
|
v-slot="{ processing }"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -135,23 +227,14 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
name="redirect_to"
|
name="redirect_to"
|
||||||
value="backlog"
|
value="backlog"
|
||||||
/>
|
/>
|
||||||
<label
|
|
||||||
:for="`backlog-ticket-status-${ticket.id}`"
|
|
||||||
class="text-label text-muted-foreground uppercase"
|
|
||||||
>
|
|
||||||
Move to status
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 sm:flex-row"
|
|
||||||
>
|
|
||||||
<select
|
<select
|
||||||
:id="`backlog-ticket-status-${ticket.id}`"
|
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:disabled="
|
:disabled="
|
||||||
allowedTargetStatuses(ticket)
|
allowedTargetStatuses(
|
||||||
.length === 0 || processing
|
ticket,
|
||||||
|
).length === 0 || processing
|
||||||
"
|
"
|
||||||
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"
|
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
|
<option
|
||||||
v-if="
|
v-if="
|
||||||
@@ -173,7 +256,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
{{
|
{{
|
||||||
ticketStatusLabel(
|
ticketStatusLabel(
|
||||||
ticketStatus,
|
ticketStatus,
|
||||||
'Unknown status',
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</option>
|
</option>
|
||||||
@@ -183,27 +265,30 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
:disabled="
|
:disabled="
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(ticket)
|
allowedTargetStatuses(
|
||||||
.length === 0
|
ticket,
|
||||||
|
).length === 0
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Move
|
Move
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<p
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
allowedTargetStatuses(ticket)
|
allowedTargetStatuses(
|
||||||
.length === 0
|
ticket,
|
||||||
|
).length === 0
|
||||||
"
|
"
|
||||||
class="text-caption text-muted-foreground"
|
class="text-muted-foreground text-xs sm:basis-full"
|
||||||
>
|
>
|
||||||
This status does not allow moving
|
This status does not allow
|
||||||
anywhere else.
|
moving anywhere else.
|
||||||
</p>
|
</p>
|
||||||
</Form>
|
</Form>
|
||||||
</template>
|
</td>
|
||||||
</TicketListItem>
|
</tr>
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,248 +1,91 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
|
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import Heading from '@/components/Heading.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 { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Card,
|
||||||
DialogContent,
|
CardContent,
|
||||||
DialogDescription,
|
CardDescription,
|
||||||
DialogFooter,
|
CardHeader,
|
||||||
DialogHeader,
|
CardTitle,
|
||||||
DialogTitle,
|
} from '@/components/ui/card';
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { useBoardDrag } from '@/composables/useBoardDrag';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
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 { 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 { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
const props = defineProps<ProjectBoardPageProps>();
|
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 breadcrumbs: BreadcrumbItem[] = [
|
||||||
const feedback = useFormProtection(moveForm, { isDirty: () => false });
|
{
|
||||||
const menuPending = ref<number | null>(null);
|
title: 'Projects',
|
||||||
const busy = computed(() => moveForm.processing || menuPending.value !== null);
|
href: projectIndex(),
|
||||||
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
|
},
|
||||||
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
|
{
|
||||||
const findTicket = (id: number) =>
|
title: props.project.name,
|
||||||
props.boardColumns
|
href: show(props.project.id),
|
||||||
.flatMap((column) => column.tickets ?? [])
|
},
|
||||||
.find((ticket) => ticket.id === id);
|
{
|
||||||
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
|
title: 'Board',
|
||||||
const statusIds =
|
href: boardIndex(props.project.id),
|
||||||
props.boardColumns
|
},
|
||||||
.find((column) => column.id === columnId)
|
];
|
||||||
?.ticket_statuses?.map((status) => status.id) ?? [];
|
|
||||||
return allowedTargetStatuses(ticket).filter((status) =>
|
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||||
statusIds.includes(status.id),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<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">
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
<header
|
<div
|
||||||
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
|
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="space-y-3">
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-3">
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
<Badge
|
{{ project.key }}
|
||||||
variant="outline"
|
</Badge>
|
||||||
class="wrap-anywhere max-w-full whitespace-normal"
|
<Heading
|
||||||
>{{ project.key }}</Badge
|
:title="`${project.name} board`"
|
||||||
>
|
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
||||||
<p class="text-caption text-muted-foreground">
|
/>
|
||||||
{{ t('tickets.count', { count: ticketCount }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<h1
|
|
||||||
class="text-headline-mobile md:text-headline wrap-anywhere"
|
<div class="flex flex-wrap gap-3">
|
||||||
>
|
|
||||||
{{ t('board.title', { name: project.name }) }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-caption text-muted-foreground max-w-2xl">
|
|
||||||
{{ t('board.description') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button v-if="canCreateTickets" as-child>
|
<Button v-if="canCreateTickets" as-child>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -252,229 +95,166 @@ watch(
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Plus aria-hidden="true" />{{
|
Create ticket
|
||||||
t('navigation.createTicket')
|
|
||||||
}}
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="backlogIndex(project.id)"
|
<Link :href="backlogIndex(project.id)"
|
||||||
><ListTodo aria-hidden="true" />{{
|
>Open backlog</Link
|
||||||
t('board.openBacklog')
|
|
||||||
}}</Link
|
|
||||||
>
|
>
|
||||||
</Button>
|
</Button>
|
||||||
<Button as-child variant="ghost">
|
|
||||||
<Link :href="show(project.id)"
|
<Button as-child variant="outline">
|
||||||
><ArrowLeft aria-hidden="true" />{{
|
<Link :href="show(project.id)">Back to project</Link>
|
||||||
t('board.backToProject')
|
|
||||||
}}</Link
|
|
||||||
>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<FeedbackState v-if="status" state="success" :title="status" />
|
<div
|
||||||
<FormFeedback
|
v-if="status"
|
||||||
:state="feedback.state.value"
|
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"
|
||||||
:description="moveForm.errors.ticket_status_id"
|
>
|
||||||
@retry="
|
{{ status }}
|
||||||
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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
|
||||||
"
|
"
|
||||||
/>
|
class="mt-3 flex flex-wrap gap-2"
|
||||||
<FeedbackState
|
|
||||||
v-if="columns.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('board.empty.title')"
|
|
||||||
:description="t('board.empty.description')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
<Badge
|
||||||
v-for="column in columns"
|
v-for="ticketStatus in boardColumn.ticket_statuses"
|
||||||
:key="column.id"
|
:key="ticketStatus.id"
|
||||||
:value="column.id"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{{
|
{{ ticketStatus.name }}
|
||||||
t('board.columnOption', {
|
</Badge>
|
||||||
name: column.name,
|
|
||||||
count: column.count,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
</div>
|
||||||
id="board-scroll-hint"
|
|
||||||
class="text-caption text-muted-foreground hidden md:block"
|
<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"
|
||||||
>
|
>
|
||||||
{{ t('board.scrollHint') }}
|
No tickets are currently in this column.
|
||||||
</p>
|
</div>
|
||||||
<p
|
|
||||||
v-if="canEditTickets"
|
<div v-else class="flex flex-col gap-4">
|
||||||
id="board-drag-hint"
|
<Card
|
||||||
class="text-caption text-muted-foreground"
|
v-for="ticket in boardColumn.tickets"
|
||||||
>
|
:key="ticket.id"
|
||||||
{{ 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="[
|
: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',
|
'gap-0',
|
||||||
selectedColumnId === column.id ? 'flex' : 'hidden',
|
ticket.is_blocked
|
||||||
dropClasses(column.id),
|
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
||||||
|
: '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<header
|
<CardHeader class="pb-4">
|
||||||
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
|
<div class="flex flex-wrap gap-2">
|
||||||
>
|
<Badge variant="outline">
|
||||||
<h2
|
{{ ticket.ticket_key }}
|
||||||
:id="`board-column-title-${column.id}`"
|
</Badge>
|
||||||
class="text-title wrap-anywhere"
|
|
||||||
>
|
|
||||||
{{ column.name }}
|
|
||||||
</h2>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
v-if="ticket.is_blocked"
|
||||||
class="shrink-0 tabular-nums"
|
variant="destructive"
|
||||||
: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,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>{{ group.tickets.length }}</span
|
|
||||||
>
|
>
|
||||||
|
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>
|
</div>
|
||||||
<p
|
<CardTitle class="text-base">
|
||||||
v-if="group.tickets.length === 0"
|
<Link
|
||||||
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
|
:href="
|
||||||
>
|
showTicket({
|
||||||
{{ 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"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-if="canEditTickets"
|
|
||||||
#actions
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
|
||||||
:disabled="
|
|
||||||
busy ||
|
|
||||||
allowedTargetStatuses(
|
|
||||||
ticket,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
: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
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
move.form({
|
|
||||||
project: project.id,
|
project: project.id,
|
||||||
ticket: ticket.id,
|
ticket: ticket.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
v-slot="{ processing, errors }"
|
class="hover:text-primary transition"
|
||||||
:error-bag="`board-ticket-${ticket.id}`"
|
>
|
||||||
:options="{
|
{{ ticket.title }}
|
||||||
preserveScroll: true,
|
</Link>
|
||||||
}"
|
</CardTitle>
|
||||||
:on-before="
|
<CardDescription
|
||||||
() =>
|
v-if="ticket.description"
|
||||||
!busy && !draggedTicket
|
class="line-clamp-3"
|
||||||
|
>
|
||||||
|
{{ ticket.description }}
|
||||||
|
</CardDescription>
|
||||||
|
<CardDescription
|
||||||
|
v-if="
|
||||||
|
ticket.is_blocked &&
|
||||||
|
ticket.blocked_comment
|
||||||
"
|
"
|
||||||
@start="menuPending = ticket.id"
|
class="mt-2 text-amber-900 dark:text-amber-200"
|
||||||
@finish="menuPending = null"
|
>
|
||||||
class="grid min-w-0 gap-2"
|
{{ 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
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
@@ -483,33 +263,22 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
:for="`ticket-status-${ticket.id}`"
|
:for="`ticket-status-${ticket.id}`"
|
||||||
class="text-caption text-muted-foreground font-semibold"
|
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
|
||||||
>{{
|
>
|
||||||
t('board.moveTicket', {
|
Move ticket
|
||||||
key: ticket.ticket_key,
|
</label>
|
||||||
})
|
<div
|
||||||
}}</label
|
class="flex flex-col gap-2 sm:flex-row"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
:id="`ticket-status-${ticket.id}`"
|
:id="`ticket-status-${ticket.id}`"
|
||||||
name="ticket_status_id"
|
name="ticket_status_id"
|
||||||
:disabled="
|
:disabled="
|
||||||
busy ||
|
|
||||||
processing ||
|
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0
|
).length === 0 || processing
|
||||||
"
|
"
|
||||||
:aria-invalid="
|
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"
|
||||||
!!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
|
<option
|
||||||
v-if="
|
v-if="
|
||||||
@@ -519,160 +288,52 @@ watch(
|
|||||||
"
|
"
|
||||||
value=""
|
value=""
|
||||||
>
|
>
|
||||||
{{ t('board.noMoves') }}
|
No allowed moves
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="target in allowedTargetStatuses(
|
v-for="ticketStatus in allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
)"
|
)"
|
||||||
:key="target.id"
|
:key="ticketStatus.id"
|
||||||
:value="target.id"
|
:value="ticketStatus.id"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
ticketStatusLabel(
|
ticketStatusLabel(
|
||||||
target,
|
ticketStatus,
|
||||||
t(
|
|
||||||
'tickets.noStatus',
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
|
||||||
:id="`ticket-status-error-${ticket.id}`"
|
|
||||||
:message="
|
|
||||||
errors.ticket_status_id
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
size="sm"
|
||||||
:disabled="
|
:disabled="
|
||||||
busy ||
|
|
||||||
processing ||
|
processing ||
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(
|
||||||
ticket,
|
ticket,
|
||||||
).length === 0
|
).length === 0
|
||||||
"
|
"
|
||||||
>{{
|
|
||||||
t(
|
|
||||||
processing
|
|
||||||
? 'board.updatingStatus'
|
|
||||||
: 'board.updateStatus',
|
|
||||||
)
|
|
||||||
}}</Button
|
|
||||||
>
|
>
|
||||||
|
Update status
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
allowedTargetStatuses(
|
allowedTargetStatuses(ticket)
|
||||||
ticket,
|
.length === 0
|
||||||
).length === 0
|
|
||||||
"
|
"
|
||||||
class="text-caption text-muted-foreground"
|
class="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
{{
|
This status does not allow moving
|
||||||
t(
|
anywhere else.
|
||||||
'board.noMovesDescription',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</Form>
|
</Form>
|
||||||
</template>
|
</CardContent>
|
||||||
</TicketCard>
|
</Card>
|
||||||
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,633 +1,349 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import {
|
import { computed } from 'vue';
|
||||||
ArrowLeft,
|
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
Check,
|
import Heading from '@/components/Heading.vue';
|
||||||
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 InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
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';
|
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectsCreatePageProps>();
|
const props = defineProps<ProjectsCreatePageProps>();
|
||||||
const { t } = useLocalization();
|
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ title: t('navigation.projects'), href: index() },
|
{
|
||||||
{ title: t('projects.create'), href: create() },
|
title: 'Projects',
|
||||||
]);
|
href: index(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'New project',
|
||||||
|
href: create(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const defaultTemplateId =
|
const defaultTemplateId =
|
||||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||||
const form = useForm(
|
|
||||||
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
|
const form = useForm({
|
||||||
{
|
copy_areas: [] as string[],
|
||||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||||
name: '',
|
|
||||||
key: '',
|
key: '',
|
||||||
|
name: '',
|
||||||
source_project_id: props.sourceProjects[0]?.id
|
source_project_id: props.sourceProjects[0]?.id
|
||||||
? String(props.sourceProjects[0].id)
|
? String(props.sourceProjects[0].id)
|
||||||
: '',
|
: '',
|
||||||
source_template_id:
|
source_template_id:
|
||||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||||
copy_areas: [] as string[],
|
});
|
||||||
},
|
|
||||||
);
|
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
||||||
const protection = useFormProtection(form);
|
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
||||||
const modes = computed(() => [
|
|
||||||
{
|
const copyAreasError = computed(() => {
|
||||||
value: 'scratch',
|
return (
|
||||||
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(
|
|
||||||
() =>
|
|
||||||
form.errors.copy_areas ??
|
form.errors.copy_areas ??
|
||||||
Object.entries(form.errors).find(([key]) =>
|
Object.entries(form.errors).find(([key]) =>
|
||||||
key.startsWith('copy_areas.'),
|
key.startsWith('copy_areas.'),
|
||||||
)?.[1],
|
)?.[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';
|
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
function submit(): void {
|
const submit = (): void => {
|
||||||
form.transform((data) => ({
|
if (form.creation_mode !== 'duplicate') {
|
||||||
...data,
|
form.source_project_id = '';
|
||||||
source_project_id:
|
form.copy_areas = [];
|
||||||
data.creation_mode === 'duplicate' ? data.source_project_id : '',
|
}
|
||||||
source_template_id:
|
|
||||||
data.creation_mode === 'template' ? data.source_template_id : '',
|
if (form.creation_mode !== 'template') {
|
||||||
copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
|
form.source_template_id = '';
|
||||||
}));
|
}
|
||||||
protection.submit(store(), { preserveScroll: true });
|
|
||||||
}
|
form.post(ProjectController.store.url(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="t('projects.create')" />
|
<Head title="New project" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<form
|
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||||
class="gap-page grid min-w-0"
|
<Heading
|
||||||
:aria-busy="form.processing"
|
title="Create project"
|
||||||
@submit.prevent="submit"
|
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
||||||
>
|
|
||||||
<FormFeedback
|
|
||||||
:state="protection.state.value"
|
|
||||||
:title="
|
|
||||||
form.processing
|
|
||||||
? t('projectCreation.creating')
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
/>
|
||||||
<fieldset
|
|
||||||
class="gap-column grid min-w-0"
|
<Card>
|
||||||
:disabled="form.processing"
|
<CardHeader>
|
||||||
aria-describedby="creation-mode-error"
|
<CardTitle>Project details</CardTitle>
|
||||||
>
|
<CardDescription>
|
||||||
<legend class="text-title mb-3">
|
The key is uppercased automatically, must be unique, and
|
||||||
{{ t('projectCreation.start') }}
|
can contain up to 5 characters.
|
||||||
</legend>
|
</CardDescription>
|
||||||
<div class="gap-column grid md:grid-cols-3">
|
</CardHeader>
|
||||||
<label
|
|
||||||
v-for="mode in modes"
|
<CardContent>
|
||||||
:key="mode.value"
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
class="relative min-w-0"
|
<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
|
<input
|
||||||
v-model="form.creation_mode"
|
v-model="form.creation_mode"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="creation_mode"
|
value="scratch"
|
||||||
:value="mode.value"
|
class="mt-1"
|
||||||
: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"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<div class="space-y-1">
|
||||||
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"
|
<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="
|
:class="
|
||||||
!mode.disabled
|
hasSourceProjects
|
||||||
? 'hover:border-primary/50'
|
? 'cursor-pointer'
|
||||||
: ''
|
: 'cursor-not-allowed opacity-60'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span
|
<input
|
||||||
class="flex items-center justify-between gap-3"
|
v-model="form.creation_mode"
|
||||||
>
|
type="radio"
|
||||||
<component
|
value="duplicate"
|
||||||
:is="mode.icon"
|
class="mt-1"
|
||||||
class="text-primary size-5"
|
:disabled="!hasSourceProjects"
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
<Check
|
<div class="space-y-1">
|
||||||
v-if="form.creation_mode === mode.value"
|
<div class="font-medium">
|
||||||
class="text-primary size-4"
|
Create from existing project
|
||||||
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>
|
</div>
|
||||||
<InputError
|
<p
|
||||||
id="creation-mode-error"
|
class="text-muted-foreground text-sm"
|
||||||
:message="form.errors.creation_mode"
|
>
|
||||||
/>
|
Copy only the settings you
|
||||||
</fieldset>
|
explicitly select. Tickets are never
|
||||||
|
duplicated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<div
|
<Label
|
||||||
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
|
class="flex items-start gap-3 rounded-xl border p-4"
|
||||||
|
:class="
|
||||||
|
hasSourceTemplates
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: 'cursor-not-allowed opacity-60'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="gap-page grid min-w-0">
|
<input
|
||||||
<section
|
v-model="form.creation_mode"
|
||||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
type="radio"
|
||||||
aria-labelledby="project-details-title"
|
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"
|
||||||
>
|
>
|
||||||
<h2 id="project-details-title" class="text-title">
|
Reuse a saved configuration
|
||||||
{{ t('projectCreation.details') }}
|
template.
|
||||||
</h2>
|
</p>
|
||||||
<div class="gap-stack grid">
|
</div>
|
||||||
<Label for="name">{{
|
</Label>
|
||||||
t('projectCreation.name')
|
</div>
|
||||||
}}</Label>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Project name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
name="name"
|
|
||||||
required
|
required
|
||||||
maxlength="255"
|
autofocus
|
||||||
:disabled="form.processing"
|
placeholder="Kanban foundation"
|
||||||
:placeholder="
|
|
||||||
t('projectCreation.namePlaceholder')
|
|
||||||
"
|
|
||||||
:invalid="!!form.errors.name"
|
|
||||||
aria-describedby="name-error"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
id="name-error"
|
|
||||||
:message="form.errors.name"
|
|
||||||
/>
|
/>
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
</div>
|
</div>
|
||||||
<div class="gap-stack grid">
|
|
||||||
<Label for="key">{{
|
<div class="grid gap-2">
|
||||||
t('projectCreation.key')
|
<Label for="key">Project key</Label>
|
||||||
}}</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="key"
|
id="key"
|
||||||
v-model="form.key"
|
v-model="form.key"
|
||||||
name="key"
|
|
||||||
required
|
required
|
||||||
maxlength="5"
|
maxlength="5"
|
||||||
:disabled="form.processing"
|
placeholder="CORE"
|
||||||
:placeholder="
|
class="uppercase"
|
||||||
t('projectCreation.keyPlaceholder')
|
|
||||||
"
|
|
||||||
class="uppercase sm:max-w-48"
|
|
||||||
:invalid="!!form.errors.key"
|
|
||||||
aria-describedby="key-help key-error"
|
|
||||||
autocapitalize="characters"
|
|
||||||
:spellcheck="false"
|
|
||||||
/>
|
/>
|
||||||
<p
|
<p class="text-muted-foreground text-sm">
|
||||||
id="key-help"
|
Example ticket keys will look like
|
||||||
class="text-caption text-muted-foreground"
|
<span class="text-foreground font-medium"
|
||||||
>
|
>CORE-1</span
|
||||||
{{ t('projectCreation.keyHelp') }}
|
>.
|
||||||
</p>
|
</p>
|
||||||
<InputError
|
<InputError :message="form.errors.key" />
|
||||||
id="key-error"
|
|
||||||
:message="form.errors.key"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
<div
|
||||||
v-if="form.creation_mode === 'duplicate'"
|
v-if="form.creation_mode === 'duplicate'"
|
||||||
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
class="space-y-6 rounded-xl border border-dashed p-4"
|
||||||
aria-labelledby="copy-title"
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_project_id"
|
||||||
|
>Source project</Label
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
<select
|
<select
|
||||||
id="source_project_id"
|
id="source_project_id"
|
||||||
v-model="form.source_project_id"
|
v-model="form.source_project_id"
|
||||||
required
|
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"
|
||||||
:class="selectClass"
|
:disabled="!hasSourceProjects"
|
||||||
:disabled="form.processing"
|
|
||||||
:aria-invalid="
|
|
||||||
!!form.errors.source_project_id
|
|
||||||
"
|
|
||||||
aria-describedby="source-project-error"
|
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{{ t('projectCreation.selectProject') }}
|
Select a source project
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="project in sourceProjects"
|
v-for="sourceProject in sourceProjects"
|
||||||
:key="project.id"
|
:key="sourceProject.id"
|
||||||
:value="String(project.id)"
|
:value="String(sourceProject.id)"
|
||||||
>
|
>
|
||||||
{{ project.name }} ({{ project.key }})
|
{{ sourceProject.name }} ({{
|
||||||
|
sourceProject.key
|
||||||
|
}})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
The new project keeps its own identity, key,
|
||||||
|
and future ticket numbering.
|
||||||
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
id="source-project-error"
|
|
||||||
:message="form.errors.source_project_id"
|
:message="form.errors.source_project_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<fieldset
|
|
||||||
class="min-w-0"
|
<div class="grid gap-3">
|
||||||
:disabled="form.processing"
|
<div>
|
||||||
aria-describedby="copy-help copy-areas-error"
|
<Label>Copy options</Label>
|
||||||
>
|
<p class="text-muted-foreground text-sm">
|
||||||
<legend class="mb-2 font-semibold">
|
Choose which settings should be copied
|
||||||
{{ t('projectCreation.copyOptions') }}
|
into the new project.
|
||||||
</legend>
|
|
||||||
<p
|
|
||||||
id="copy-help"
|
|
||||||
class="text-caption text-muted-foreground mb-3"
|
|
||||||
>
|
|
||||||
{{ t('projectCreation.copyDefaults') }}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="gap-stack grid sm:grid-cols-2">
|
</div>
|
||||||
<label
|
|
||||||
v-for="area in copyAreaOptions"
|
<div class="grid gap-3">
|
||||||
:key="area.value"
|
<Label
|
||||||
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"
|
v-for="copyAreaOption in copyAreaOptions"
|
||||||
|
:key="copyAreaOption.value"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.copy_areas"
|
v-model="form.copy_areas"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="area.value"
|
:value="copyAreaOption.value"
|
||||||
:aria-labelledby="`copy-${area.value}-label`"
|
class="mt-1"
|
||||||
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
|
|
||||||
:aria-invalid="!!copyAreasError"
|
|
||||||
class="accent-primary mt-0.5 size-5 shrink-0"
|
|
||||||
/>
|
/>
|
||||||
<span class="gap-stack grid min-w-0">
|
<div class="space-y-1">
|
||||||
<span
|
<div class="font-medium">
|
||||||
:id="`copy-${area.value}-label`"
|
{{ copyAreaOption.label }}
|
||||||
class="text-body font-semibold"
|
|
||||||
>{{
|
|
||||||
areaText(
|
|
||||||
area.value,
|
|
||||||
'label',
|
|
||||||
)
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:id="`copy-${area.value}-description`"
|
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
>{{
|
|
||||||
areaText(
|
|
||||||
area.value,
|
|
||||||
'description',
|
|
||||||
)
|
|
||||||
}}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<InputError
|
<p
|
||||||
id="copy-areas-error"
|
class="text-muted-foreground text-sm"
|
||||||
class="mt-3"
|
|
||||||
:message="copyAreasError"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<h2 id="template-title" class="text-title">
|
{{ copyAreaOption.description }}
|
||||||
{{ t('projectCreation.chooseTemplate') }}
|
</p>
|
||||||
</h2>
|
</div>
|
||||||
<div class="gap-stack grid min-w-0">
|
</Label>
|
||||||
<Label for="source_template_id">{{
|
</div>
|
||||||
t('projectCreation.templateLabel')
|
|
||||||
}}</Label>
|
<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="space-y-4 rounded-xl border border-dashed p-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_template_id">Template</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="form.source_template_id"
|
v-model="form.source_template_id"
|
||||||
required
|
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"
|
||||||
:class="selectClass"
|
:disabled="!hasSourceTemplates"
|
||||||
:disabled="form.processing"
|
|
||||||
:aria-invalid="
|
|
||||||
!!form.errors.source_template_id
|
|
||||||
"
|
|
||||||
aria-describedby="template-help source-template-error"
|
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">Select a template</option>
|
||||||
{{
|
|
||||||
t('projectCreation.selectTemplate')
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
<option
|
<option
|
||||||
v-for="template in sourceTemplates"
|
v-for="sourceTemplate in sourceTemplates"
|
||||||
:key="template.id"
|
:key="sourceTemplate.id"
|
||||||
:value="String(template.id)"
|
:value="String(sourceTemplate.id)"
|
||||||
>
|
>
|
||||||
{{ template.name }} ({{
|
{{ sourceTemplate.name }} ({{
|
||||||
template.slug
|
sourceTemplate.slug
|
||||||
}})
|
}})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p
|
<p class="text-muted-foreground text-sm">
|
||||||
id="template-help"
|
Templates apply their saved configuration
|
||||||
class="text-caption text-muted-foreground"
|
groups to the new project.
|
||||||
>
|
|
||||||
{{ t('projectCreation.templateHelp') }}
|
|
||||||
</p>
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
id="source-template-error"
|
|
||||||
:message="form.errors.source_template_id"
|
:message="form.errors.source_template_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="form.creation_mode === 'duplicate'"
|
v-else-if="
|
||||||
class="gap-stack grid"
|
!hasSourceProjects && !hasSourceTemplates
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{{ t('projectCreation.cleanStart') }}
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</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')"
|
class="text-muted-foreground text-sm"
|
||||||
><FolderPlus
|
|
||||||
v-if="!form.processing"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>{{
|
|
||||||
t(
|
|
||||||
form.processing
|
|
||||||
? 'projectCreation.creating'
|
|
||||||
: 'projectCreation.submit',
|
|
||||||
)
|
|
||||||
}}</Button
|
|
||||||
>
|
>
|
||||||
</footer>
|
No accessible source projects or saved templates are
|
||||||
|
available yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,184 +1,103 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import {
|
import { Folder, Plus } from 'lucide-vue-next';
|
||||||
ArrowUpRight,
|
import Heading from '@/components/Heading.vue';
|
||||||
Columns3,
|
|
||||||
FolderGit2,
|
|
||||||
ListTodo,
|
|
||||||
Plus,
|
|
||||||
} from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index, show } from '@/routes/projects';
|
import { create, index, show } from '@/routes/projects/index';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
|
||||||
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
||||||
|
|
||||||
defineProps<ProjectsIndexPageProps>();
|
defineProps<ProjectsIndexPageProps>();
|
||||||
const { t } = useLocalization();
|
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ title: t('navigation.projects'), href: index() },
|
{
|
||||||
]);
|
title: 'Projects',
|
||||||
|
href: index(),
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<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">
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
<header
|
<div
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="gap-stack grid min-w-0">
|
<Heading
|
||||||
<p class="text-label text-muted-foreground uppercase">
|
title="Projects"
|
||||||
{{ t('projects.workspace') }}
|
description="Create and browse project spaces that will later host boards, backlog, and settings."
|
||||||
</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>
|
|
||||||
<Button
|
|
||||||
v-if="$page.props.auth.permissions.projectCreate"
|
|
||||||
as-child
|
|
||||||
class="self-start"
|
|
||||||
>
|
|
||||||
<Link :href="create()"
|
|
||||||
><Plus aria-hidden="true" />{{
|
|
||||||
t('projects.create')
|
|
||||||
}}</Link
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<FeedbackState
|
|
||||||
v-if="status"
|
|
||||||
state="success"
|
|
||||||
:title="t('projects.updated')"
|
|
||||||
:description="status"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FeedbackState
|
|
||||||
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')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="$page.props.auth.permissions.projectCreate"
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
as-child
|
as-child
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link :href="create()"
|
|
||||||
><Plus aria-hidden="true" />{{
|
|
||||||
t('projects.empty.create')
|
|
||||||
}}</Link
|
|
||||||
>
|
>
|
||||||
|
<Link :href="create()">
|
||||||
|
<Plus class="size-4" />
|
||||||
|
New project
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</FeedbackState>
|
</div>
|
||||||
|
|
||||||
<section
|
<div
|
||||||
v-else
|
v-if="status"
|
||||||
aria-labelledby="project-list-title"
|
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"
|
||||||
class="gap-column grid"
|
|
||||||
>
|
>
|
||||||
<h2
|
{{ status }}
|
||||||
id="project-list-title"
|
</div>
|
||||||
class="text-caption text-muted-foreground"
|
|
||||||
|
<div
|
||||||
|
v-if="projects.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
|
||||||
>
|
>
|
||||||
{{ t('projects.available', { count: projects.length }) }}
|
No projects exist yet. Create the first one to start building
|
||||||
</h2>
|
out board, backlog, and settings areas.
|
||||||
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
|
</div>
|
||||||
<li
|
|
||||||
|
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Link
|
||||||
v-for="project in projects"
|
v-for="project in projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
class="min-w-0"
|
|
||||||
>
|
|
||||||
<article
|
|
||||||
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
:href="show(project.id)"
|
:href="show(project.id)"
|
||||||
:aria-label="
|
class="group block"
|
||||||
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
|
<Card
|
||||||
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
|
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
<ArrowUpRight
|
|
||||||
class="text-muted-foreground mt-1 size-4 shrink-0"
|
<Badge variant="outline" class="px-2.5 py-1">
|
||||||
aria-hidden="true"
|
{{ project.key }}
|
||||||
/>
|
</Badge>
|
||||||
</Link>
|
</div>
|
||||||
<div
|
</CardHeader>
|
||||||
class="gap-stack p-card grid grid-cols-2 border-t"
|
|
||||||
>
|
<CardContent
|
||||||
<Button as-child variant="secondary">
|
class="text-muted-foreground flex items-center gap-2 text-sm"
|
||||||
<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" />{{
|
<Folder class="size-4" />
|
||||||
t('navigation.backlog')
|
Open board, backlog, and settings hub
|
||||||
}}
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,227 +1,206 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import {
|
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
||||||
ArrowLeft,
|
import Heading from '@/components/Heading.vue';
|
||||||
ArrowRight,
|
|
||||||
Columns3,
|
|
||||||
Layers3,
|
|
||||||
ListTodo,
|
|
||||||
} from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { projectSettingsNavigation } from '@/lib/navigation';
|
import { access, index, show } from '@/routes/projects/index';
|
||||||
import { index, show } from '@/routes/projects';
|
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
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 { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectShowPageProps>();
|
const props = defineProps<ProjectShowPageProps>();
|
||||||
const { t } = useLocalization();
|
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ title: t('navigation.projects'), href: index() },
|
|
||||||
{ title: props.project.name, href: show(props.project.id) },
|
|
||||||
]);
|
|
||||||
const projectAreas = computed(() => [
|
|
||||||
{
|
{
|
||||||
title: t('navigation.board'),
|
title: 'Projects',
|
||||||
description: t('projects.boardDescription'),
|
href: index(),
|
||||||
action: t('projects.boardAction'),
|
|
||||||
href: boardIndex(props.project.id),
|
|
||||||
icon: Columns3,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.backlog'),
|
title: props.project.name,
|
||||||
description: t('projects.backlogDescription'),
|
href: show(props.project.id),
|
||||||
action: t('projects.backlogAction'),
|
},
|
||||||
href: backlogIndex(props.project.id),
|
];
|
||||||
|
|
||||||
|
const projectAreas = [
|
||||||
|
{
|
||||||
|
title: 'Backlog',
|
||||||
|
description:
|
||||||
|
'Create tickets, review incoming work, and move ready items into the board workflow.',
|
||||||
icon: ListTodo,
|
icon: ListTodo,
|
||||||
},
|
},
|
||||||
]);
|
|
||||||
const settingsItems = computed(() =>
|
|
||||||
projectSettingsNavigation(
|
|
||||||
{
|
{
|
||||||
id: props.project.id,
|
title: 'Board',
|
||||||
name: props.project.name,
|
description:
|
||||||
key: props.project.key,
|
'Track active work across the project-defined Kanban columns and update ticket status.',
|
||||||
canManage: props.canManageAccess,
|
icon: LayoutGrid,
|
||||||
canCreateTickets: false,
|
|
||||||
},
|
},
|
||||||
t,
|
{
|
||||||
),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="project.name" />
|
<Head :title="project.name" />
|
||||||
|
|
||||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
<Link
|
<div
|
||||||
: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
|
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="gap-stack grid min-w-0">
|
<div class="space-y-3">
|
||||||
<Badge
|
<Badge variant="outline" class="px-3 py-1">
|
||||||
variant="outline"
|
{{ project.key }}
|
||||||
class="max-w-full justify-self-start whitespace-normal break-all"
|
</Badge>
|
||||||
>{{ project.key }}</Badge
|
<Heading
|
||||||
>
|
:title="project.name"
|
||||||
<h1
|
description="Project hub for the upcoming backlog, board, and settings work."
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
<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
|
<Button
|
||||||
v-if="$page.props.auth.permissions.projectCreate"
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
as-child
|
as-child
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="self-start"
|
|
||||||
>
|
|
||||||
<Link :href="projectTemplateCreate(project.id)"
|
|
||||||
><Layers3 aria-hidden="true" />{{
|
|
||||||
t('projects.saveTemplate')
|
|
||||||
}}</Link
|
|
||||||
>
|
>
|
||||||
|
<Link :href="projectTemplateCreate(project.id)">
|
||||||
|
Save as new template
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
|
||||||
|
|
||||||
<section
|
<Button as-child variant="outline">
|
||||||
:aria-label="t('projects.workAreas')"
|
<Link :href="index()">Back to projects</Link>
|
||||||
class="gap-column grid md:grid-cols-2"
|
</Button>
|
||||||
>
|
</div>
|
||||||
<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>
|
</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
|
<Card v-if="templateState?.inheritsFromTemplate">
|
||||||
v-if="settingsItems.length"
|
<CardHeader>
|
||||||
aria-labelledby="project-settings-title"
|
<CardTitle>
|
||||||
class="gap-column grid"
|
Built from template {{ templateState.templateName }}
|
||||||
>
|
</CardTitle>
|
||||||
<div class="gap-stack grid">
|
<CardDescription>
|
||||||
<h2 id="project-settings-title" class="text-title">
|
This project keeps its template link. Inherited areas
|
||||||
{{ t('navigation.projectSettings') }}
|
follow the template baseline; overridden areas were
|
||||||
</h2>
|
customized locally.
|
||||||
<p class="text-muted-foreground">
|
</CardDescription>
|
||||||
{{ t('projects.settingsDescription') }}
|
</CardHeader>
|
||||||
</p>
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
</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="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">
|
|
||||||
<Badge
|
<Badge
|
||||||
v-for="area in templateState.inheritedAreas"
|
v-for="area in templateState.inheritedAreas"
|
||||||
:key="`inherited-${area.value}`"
|
:key="`inherited-${area.value}`"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="max-w-full whitespace-normal break-words"
|
|
||||||
>
|
>
|
||||||
{{ t('projects.inheritedArea', { name: area.label }) }}
|
{{ area.label }} · inherited
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-for="area in templateState.overriddenAreas"
|
v-for="area in templateState.overriddenAreas"
|
||||||
:key="`overridden-${area.value}`"
|
:key="`overridden-${area.value}`"
|
||||||
variant="secondary"
|
|
||||||
class="max-w-full whitespace-normal break-words"
|
|
||||||
>
|
>
|
||||||
{{ t('projects.overriddenArea', { name: area.label }) }}
|
{{ area.label }} · overridden
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</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>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
||||||
Form,
|
|
||||||
useForm,
|
|
||||||
useRemember,
|
|
||||||
Head,
|
|
||||||
Link,
|
|
||||||
usePage,
|
|
||||||
} from '@inertiajs/vue3';
|
|
||||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||||
import { computed, ref, toRef, unref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
acceptanceCriteriaToEditorState,
|
acceptanceCriteriaToEditorState,
|
||||||
buildAcceptanceCriteria,
|
buildAcceptanceCriteria,
|
||||||
@@ -21,10 +14,6 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
|
|||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -56,7 +45,6 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
|
|||||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketPageProps>();
|
const props = defineProps<ProjectTicketPageProps>();
|
||||||
const { t } = useLocalization();
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const appName = String(page.props.name);
|
const appName = String(page.props.name);
|
||||||
|
|
||||||
@@ -134,14 +122,8 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
||||||
props.ticket.acceptance_criteria,
|
props.ticket.acceptance_criteria,
|
||||||
);
|
);
|
||||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
const acceptanceCriteriaChecklist = ref(
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
acceptanceCriteriaEditorState.checklist,
|
||||||
{ checklist: acceptanceCriteriaEditorState.checklist },
|
|
||||||
`${draftKey}:checklist`,
|
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
);
|
||||||
const savedAcceptanceCriteria = ref(
|
const savedAcceptanceCriteria = ref(
|
||||||
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||||
@@ -149,7 +131,7 @@ const savedAcceptanceCriteria = ref(
|
|||||||
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
const isPersistingAcceptanceCriteriaToggle = ref(false);
|
||||||
const acceptanceCriteriaToggleError = ref('');
|
const acceptanceCriteriaToggleError = ref('');
|
||||||
|
|
||||||
const form = useForm(draftKey, {
|
const form = useForm({
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
acceptance_criteria: buildAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value,
|
acceptanceCriteriaChecklist.value,
|
||||||
),
|
),
|
||||||
@@ -188,12 +170,6 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
|
|||||||
formatChecklistLines(savedAcceptanceCriteria.value),
|
formatChecklistLines(savedAcceptanceCriteria.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
const protection = useFormProtection(form, {
|
|
||||||
isDirty: () =>
|
|
||||||
props.canEditTicket &&
|
|
||||||
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
|
|
||||||
});
|
|
||||||
|
|
||||||
const acceptanceCriteriaError = computed(() => {
|
const acceptanceCriteriaError = computed(() => {
|
||||||
return (
|
return (
|
||||||
form.errors.acceptance_criteria ??
|
form.errors.acceptance_criteria ??
|
||||||
@@ -211,20 +187,10 @@ const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
|||||||
const branchCreationForm = useForm<{
|
const branchCreationForm = useForm<{
|
||||||
branch_name: string;
|
branch_name: string;
|
||||||
repository_connection_id: string;
|
repository_connection_id: string;
|
||||||
}>(`${draftKey}:branch`, {
|
}>({
|
||||||
branch_name: props.suggestedBranchName,
|
branch_name: props.suggestedBranchName,
|
||||||
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
||||||
});
|
});
|
||||||
const branchProtection = useFormProtection(branchCreationForm, {
|
|
||||||
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateBranchDialogOpen = (open: boolean): void => {
|
|
||||||
if (!open && !branchProtection.confirmLeave()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isCreateBranchDialogOpen.value = open;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedBranchRepositoryConnection = computed(
|
const selectedBranchRepositoryConnection = computed(
|
||||||
(): RepositoryConnectionModel | null =>
|
(): RepositoryConnectionModel | null =>
|
||||||
@@ -241,9 +207,6 @@ const selectedBranchRepositoryHasPersonalAccess = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const initializeBranchCreationForm = (): void => {
|
const initializeBranchCreationForm = (): void => {
|
||||||
if (branchCreationForm.isDirty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
branchCreationForm.clearErrors();
|
branchCreationForm.clearErrors();
|
||||||
branchCreationForm.branch_name = props.suggestedBranchName;
|
branchCreationForm.branch_name = props.suggestedBranchName;
|
||||||
branchCreationForm.repository_connection_id =
|
branchCreationForm.repository_connection_id =
|
||||||
@@ -385,7 +348,7 @@ const submitBranchCreation = (): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
branchProtection.submit(
|
branchCreationForm.submit(
|
||||||
ProjectTicketBranchController.store({
|
ProjectTicketBranchController.store({
|
||||||
project: props.project.id,
|
project: props.project.id,
|
||||||
ticket: props.ticket.id,
|
ticket: props.ticket.id,
|
||||||
@@ -519,21 +482,13 @@ const submit = (): void => {
|
|||||||
|
|
||||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
||||||
|
|
||||||
protection.submit(
|
form.submit(
|
||||||
ProjectTicketController.update({
|
ProjectTicketController.update({
|
||||||
project: props.project.id,
|
project: props.project.id,
|
||||||
ticket: props.ticket.id,
|
ticket: props.ticket.id,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
|
||||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
|
||||||
props.ticket.acceptance_criteria,
|
|
||||||
);
|
|
||||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
|
||||||
savedAcceptanceCriteria.value,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -619,25 +574,16 @@ const formatDateTime = (value: string): string => {
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="grid gap-6" @submit.prevent="submit">
|
<form class="grid gap-6" @submit.prevent="submit">
|
||||||
<FormFeedback
|
|
||||||
:state="protection.state.value"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="title">Title</Label>
|
<Label for="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
:invalid="Boolean(form.errors.title)"
|
|
||||||
aria-describedby="title-error"
|
|
||||||
:disabled="
|
:disabled="
|
||||||
!canEditTicket || form.processing
|
!canEditTicket || form.processing
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError :message="form.errors.title" />
|
||||||
id="title-error"
|
|
||||||
:message="form.errors.title"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
|
||||||
@@ -1049,10 +995,7 @@ const formatDateTime = (value: string): string => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog v-model:open="isCreateBranchDialogOpen">
|
||||||
:open="isCreateBranchDialogOpen"
|
|
||||||
@update:open="updateBranchDialogOpen"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="canCreateBranches"
|
v-if="canCreateBranches"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1080,12 +1023,6 @@ const formatDateTime = (value: string): string => {
|
|||||||
class="grid gap-4"
|
class="grid gap-4"
|
||||||
@submit.prevent="submitBranchCreation"
|
@submit.prevent="submitBranchCreation"
|
||||||
>
|
>
|
||||||
<FormFeedback
|
|
||||||
:state="
|
|
||||||
branchProtection.state.value
|
|
||||||
"
|
|
||||||
@retry="submitBranchCreation"
|
|
||||||
/>
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label
|
<Label
|
||||||
for="repository_connection_id"
|
for="repository_connection_id"
|
||||||
@@ -1216,9 +1153,7 @@ const formatDateTime = (value: string): string => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@click="
|
@click="
|
||||||
updateBranchDialogOpen(
|
isCreateBranchDialogOpen = false
|
||||||
false,
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -1242,14 +1177,13 @@ const formatDateTime = (value: string): string => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<FeedbackState
|
<div
|
||||||
v-if="visibleRepositoryBranches.length === 0"
|
v-if="visibleRepositoryBranches.length === 0"
|
||||||
state="empty"
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||||
:title="t('tickets.noBranches')"
|
>
|
||||||
:description="
|
No matching branches were found across the
|
||||||
t('tickets.noBranchesDescription')
|
linked repositories you can access.
|
||||||
"
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
@@ -1500,16 +1434,15 @@ const formatDateTime = (value: string): string => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<FeedbackState
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
visibleRepositoryPullRequests.length === 0
|
visibleRepositoryPullRequests.length === 0
|
||||||
"
|
"
|
||||||
state="empty"
|
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||||
:title="t('tickets.noPullRequests')"
|
>
|
||||||
:description="
|
No matching pull requests were found across the
|
||||||
t('tickets.noPullRequestsDescription')
|
linked repositories you can access.
|
||||||
"
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, toRef, unref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -64,17 +62,9 @@ const hasTicketMetadata =
|
|||||||
props.ticketPriorities.length > 0 &&
|
props.ticketPriorities.length > 0 &&
|
||||||
props.ticketStatusOptions.length > 0;
|
props.ticketStatusOptions.length > 0;
|
||||||
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
const acceptanceCriteriaChecklist = ref('');
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
|
||||||
{ checklist: '' },
|
|
||||||
`${draftKey}:checklist`,
|
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm(draftKey, {
|
const form = useForm({
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
acceptance_criteria: buildAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value,
|
acceptanceCriteriaChecklist.value,
|
||||||
),
|
),
|
||||||
@@ -90,10 +80,6 @@ const form = useForm(draftKey, {
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const protection = useFormProtection(form, {
|
|
||||||
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||||
);
|
);
|
||||||
@@ -113,12 +99,9 @@ const submit = (): void => {
|
|||||||
acceptanceCriteriaChecklist.value,
|
acceptanceCriteriaChecklist.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
protection.submit(
|
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||||
ProjectTicketController.store({ project: props.project.id }),
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||||
@@ -182,26 +165,17 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="grid gap-6" @submit.prevent="submit">
|
<form class="grid gap-6" @submit.prevent="submit">
|
||||||
<FormFeedback
|
|
||||||
:state="protection.state.value"
|
|
||||||
@retry="submit"
|
|
||||||
/>
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="title">Title</Label>
|
<Label for="title">Title</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
:invalid="Boolean(form.errors.title)"
|
|
||||||
aria-describedby="title-error"
|
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
placeholder="Implement repository sync"
|
placeholder="Implement repository sync"
|
||||||
/>
|
/>
|
||||||
<InputError
|
<InputError :message="form.errors.title" />
|
||||||
id="title-error"
|
|
||||||
:message="form.errors.title"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -39,15 +39,6 @@ export type ProjectDuplicateCopyAreaOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssignedTicketsProjectGroup = {
|
|
||||||
project: ProjectModel;
|
|
||||||
tickets: TicketModel[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DashboardPageProps = {
|
|
||||||
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProjectsIndexPageProps = {
|
export type ProjectsIndexPageProps = {
|
||||||
projects: ProjectModel[];
|
projects: ProjectModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\DashboardController;
|
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -18,7 +17,7 @@ Route::get('api/docs/openapi.json', function (): Response {
|
|||||||
})->name('api.docs.openapi');
|
})->name('api.docs.openapi');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
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/admin.php';
|
||||||
require __DIR__.'/web/project-templates.php';
|
require __DIR__.'/web/project-templates.php';
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace Tests\Feature\Auth;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
@@ -86,7 +85,7 @@ class AuthenticationTest extends TestCase
|
|||||||
$this->post(route('login.store'), [
|
$this->post(route('login.store'), [
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'password' => 'wrong-password',
|
'password' => 'wrong-password',
|
||||||
])->assertSessionHasErrors('email');
|
]);
|
||||||
|
|
||||||
$this->assertGuest();
|
$this->assertGuest();
|
||||||
}
|
}
|
||||||
@@ -101,22 +100,6 @@ class AuthenticationTest extends TestCase
|
|||||||
$response->assertRedirect(route('home'));
|
$response->assertRedirect(route('home'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_login_preserves_the_intended_destination_and_remember_choice(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->withSession(['url.intended' => route('dashboard')])
|
|
||||||
->post(route('login.store'), [
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'password',
|
|
||||||
'remember' => true,
|
|
||||||
])
|
|
||||||
->assertRedirect(route('dashboard'))
|
|
||||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
|
||||||
|
|
||||||
$this->assertAuthenticatedAs($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_users_are_rate_limited()
|
public function test_users_are_rate_limited()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class EmailVerificationTest extends TestCase
|
|||||||
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
$this->actingAs($user)->get($verificationUrl);
|
||||||
|
|
||||||
Event::assertNotDispatched(Verified::class);
|
Event::assertNotDispatched(Verified::class);
|
||||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||||
@@ -79,7 +79,7 @@ class EmailVerificationTest extends TestCase
|
|||||||
['id' => 123, 'hash' => sha1($user->email)],
|
['id' => 123, 'hash' => sha1($user->email)],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
$this->actingAs($user)->get($verificationUrl);
|
||||||
|
|
||||||
Event::assertNotDispatched(Verified::class);
|
Event::assertNotDispatched(Verified::class);
|
||||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||||
@@ -97,34 +97,6 @@ class EmailVerificationTest extends TestCase
|
|||||||
$response->assertRedirect(route('dashboard', absolute: false));
|
$response->assertRedirect(route('dashboard', absolute: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_the_ui_rework_preserves_the_existing_optional_verification_policy(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)->get(route('dashboard'))->assertOk();
|
|
||||||
|
|
||||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_guests_cannot_view_the_verification_prompt(): void
|
|
||||||
{
|
|
||||||
$this->get(route('verification.notice'))->assertRedirect(route('login'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_expired_verification_links_cannot_verify_an_email(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
$verificationUrl = URL::temporarySignedRoute(
|
|
||||||
'verification.verify',
|
|
||||||
now()->subMinute(),
|
|
||||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
|
||||||
|
|
||||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
|
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_confirm_password_screen_can_be_rendered(): void
|
public function test_confirm_password_screen_can_be_rendered()
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
@@ -24,32 +24,10 @@ class PasswordConfirmationTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_password_confirmation_requires_authentication(): void
|
public function test_password_confirmation_requires_authentication()
|
||||||
{
|
{
|
||||||
$response = $this->get(route('password.confirm'));
|
$response = $this->get(route('password.confirm'));
|
||||||
|
|
||||||
$response->assertRedirect(route('login'));
|
$response->assertRedirect(route('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_password_can_be_confirmed(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post(route('password.confirm'), ['password' => 'password'])
|
|
||||||
->assertSessionHasNoErrors();
|
|
||||||
|
|
||||||
$this->assertNotNull(session('auth.password_confirmed_at'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_password_confirmation_rejects_an_invalid_password(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->post(route('password.confirm'), ['password' => 'incorrect'])
|
|
||||||
->assertSessionHasErrors('password');
|
|
||||||
|
|
||||||
$this->assertNull(session('auth.password_confirmed_at'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Models\User;
|
|||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -21,16 +20,11 @@ class PasswordResetTest extends TestCase
|
|||||||
$this->skipUnlessFortifyFeature(Features::resetPasswords());
|
$this->skipUnlessFortifyFeature(Features::resetPasswords());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
public function test_reset_password_link_screen_can_be_rendered()
|
||||||
{
|
{
|
||||||
$response = $this->get(route('password.request'));
|
$response = $this->get(route('password.request'));
|
||||||
|
|
||||||
$response
|
$response->assertOk();
|
||||||
->assertOk()
|
|
||||||
->assertInertia(fn (Assert $page) => $page
|
|
||||||
->component('auth/ForgotPassword')
|
|
||||||
->where('status', null),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_reset_password_link_can_be_requested()
|
public function test_reset_password_link_can_be_requested()
|
||||||
@@ -44,7 +38,7 @@ class PasswordResetTest extends TestCase
|
|||||||
Notification::assertSentTo($user, ResetPassword::class);
|
Notification::assertSentTo($user, ResetPassword::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_reset_password_screen_can_be_rendered(): void
|
public function test_reset_password_screen_can_be_rendered()
|
||||||
{
|
{
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
|
|
||||||
@@ -52,19 +46,10 @@ class PasswordResetTest extends TestCase
|
|||||||
|
|
||||||
$this->post(route('password.email'), ['email' => $user->email]);
|
$this->post(route('password.email'), ['email' => $user->email]);
|
||||||
|
|
||||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||||
$response = $this->get(route('password.reset', [
|
$response = $this->get(route('password.reset', $notification->token));
|
||||||
'token' => $notification->token,
|
|
||||||
'email' => $user->email,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$response
|
$response->assertOk();
|
||||||
->assertOk()
|
|
||||||
->assertInertia(fn (Assert $page) => $page
|
|
||||||
->component('auth/ResetPassword')
|
|
||||||
->where('email', $user->email)
|
|
||||||
->where('token', $notification->token),
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ namespace Tests\Feature\Auth;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -54,77 +52,4 @@ class TwoFactorChallengeTest extends TestCase
|
|||||||
->component('auth/TwoFactorChallenge'),
|
->component('auth/TwoFactorChallenge'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_valid_authenticator_code_completes_the_pending_login(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->withTwoFactor()->create();
|
|
||||||
$this->mock(TwoFactorAuthenticationProvider::class)
|
|
||||||
->shouldReceive('verify')->once()->with('secret', '012345')->andReturnTrue();
|
|
||||||
|
|
||||||
$this->withSession(['url.intended' => route('dashboard')])
|
|
||||||
->post(route('login.store'), [
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'password',
|
|
||||||
'remember' => true,
|
|
||||||
])->assertRedirect(route('two-factor.login'));
|
|
||||||
$this->assertGuest();
|
|
||||||
|
|
||||||
$this->post(route('two-factor.login.store'), ['code' => '012345'])
|
|
||||||
->assertRedirect(route('dashboard'))
|
|
||||||
->assertSessionMissing('login.id')
|
|
||||||
->assertSessionMissing('login.remember')
|
|
||||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
|
||||||
|
|
||||||
$this->assertAuthenticatedAs($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_invalid_authenticator_code_keeps_the_pending_login_for_retry(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->withTwoFactor()->create();
|
|
||||||
$this->mock(TwoFactorAuthenticationProvider::class)
|
|
||||||
->shouldReceive('verify')->once()->with('secret', '000000')->andReturnFalse();
|
|
||||||
|
|
||||||
$this->withSession(['login.id' => $user->id])
|
|
||||||
->from(route('two-factor.login'))
|
|
||||||
->post(route('two-factor.login.store'), ['code' => '000000'])
|
|
||||||
->assertRedirect(route('two-factor.login'))
|
|
||||||
->assertSessionHasErrors('code')
|
|
||||||
->assertSessionHas('login.id', $user->id);
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_recovery_code_signs_in_once_and_is_replaced(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->withTwoFactor()->create();
|
|
||||||
|
|
||||||
$this->withSession(['login.id' => $user->id, 'login.remember' => true])
|
|
||||||
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
|
|
||||||
->assertRedirect(route('dashboard', absolute: false))
|
|
||||||
->assertSessionMissing('login.id')
|
|
||||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
|
||||||
|
|
||||||
$this->assertAuthenticatedAs($user);
|
|
||||||
$this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes());
|
|
||||||
$this->assertCount(1, $user->fresh()->recoveryCodes());
|
|
||||||
|
|
||||||
$this->post(route('logout'));
|
|
||||||
$this->withSession(['login.id' => $user->id])
|
|
||||||
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
|
|
||||||
->assertSessionHasErrors('recovery_code')
|
|
||||||
->assertSessionHas('login.id', $user->id);
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_challenge_submission_cannot_sign_in_without_a_pending_login(): void
|
|
||||||
{
|
|
||||||
$this->from(route('two-factor.login'))
|
|
||||||
->post(route('two-factor.login.store'), ['code' => '012345'])
|
|
||||||
->assertRedirect(route('two-factor.login'))
|
|
||||||
->assertSessionHasErrors('code');
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
$this->get(route('two-factor.login'))->assertRedirect(route('login'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user