From 823cd6391d026c15407583f42469b24eaea8d44c Mon Sep 17 00:00:00 2001 From: Daniel Stock Date: Tue, 24 Feb 2026 16:15:21 +0100 Subject: [PATCH] Connect CalDAV todos to DB-Items, finish todo component and add it to pipeline items --- app/Console/Commands/CaldavSyncCommand.php | 49 +++++++- app/Http/Controllers/LineItemController.php | 2 +- app/Http/Controllers/NoteController.php | 11 +- app/Http/Controllers/PipelineController.php | 37 ++++-- .../Controllers/PipelineItemController.php | 60 ++++++--- app/Http/Controllers/TodoController.php | 54 +++++++- app/Models/PipelineItem.php | 8 ++ app/Models/Todo.php | 4 +- .../2025_11_28_000000_create_todo_table.php | 3 + resources/css/app.css | 99 +++++++++++++-- .../components/EditorDialog/EditorDialog.vue | 2 +- resources/js/components/TextEditor.vue | 118 ++++-------------- resources/js/components/TextEditorMenu.vue | 104 +++++++++++++++ resources/js/components/Todos.vue | 29 +++-- .../js/components/documents/DocumentTable.vue | 4 +- .../js/components/documents/InvoiceDialog.vue | 22 ++-- .../js/components/documents/LineItemTable.vue | 22 ++-- resources/js/components/ui/crm-badge/index.ts | 2 +- resources/js/pages/Dashboard.vue | 15 ++- resources/js/pages/Pipeline.vue | 73 +++++++---- resources/js/pages/Timesheets.vue | 5 +- resources/js/services/NotesService.ts | 8 +- resources/js/services/PipelineService.ts | 32 +++++ resources/js/services/TodoService.ts | 25 ++++ resources/js/types/index.d.ts | 14 ++- resources/views/invoice.blade.php | 4 +- routes/api.php | 4 +- 27 files changed, 605 insertions(+), 205 deletions(-) create mode 100644 resources/js/components/TextEditorMenu.vue create mode 100644 resources/js/services/TodoService.ts diff --git a/app/Console/Commands/CaldavSyncCommand.php b/app/Console/Commands/CaldavSyncCommand.php index 0894c85..170938a 100644 --- a/app/Console/Commands/CaldavSyncCommand.php +++ b/app/Console/Commands/CaldavSyncCommand.php @@ -7,13 +7,14 @@ use Illuminate\Support\Facades\Cache; use App\Services\CaldavService; use App\Models\Todo; +use App\Models\PipelineItem; class CaldavSyncCommand extends Command { protected $signature = 'caldav:sync'; protected $description = 'Sync CalDAV VTODOs into local todos table'; - public function handle(CaldavService $service) + public function handle(CaldavService $service) { // only run every 5 minutes although the task is called every minute $cacheKey = 'caldav_sync_last_run'; @@ -30,9 +31,53 @@ public function handle(CaldavService $service) $count = 0; foreach ($todos as $todo) { - Todo::upsert($todo->attributesToArray(), 'id'); + // Only update the fields that are present in CalDAV + $data = $todo->attributesToArray(); + $data = array_intersect_key($data, array_flip([ + 'id', + 'etag', + 'title', + 'description', + 'type_id', + 'url', + 'due_date', + 'recurring', + 'priority', + 'status', + 'created_at', + 'last_modified', + 'parent', + 'object', + ])); + + // Parse the title to extract the todoable title and todo title + if (isset($data['title'])) { + $titleParts = explode('] ', $data['title'], 2); + if (count($titleParts) === 2) { + $todoableTitle = trim($titleParts[0], '[]'); + $todoTitle = $titleParts[1]; + + // Find the todoable model by title + $models = [ + 'PipelineItem' => PipelineItem::class, + // Add other models here as needed + ]; + + foreach ($models as $modelName => $modelClass) { + $todoable = $modelClass::where('title', $todoableTitle)->first(); + if ($todoable) { + $data['todoable_type'] = 'App\\Models\\' . $modelName; + $data['todoable_id'] = $todoable->id; + break; + } + } + } + } + + Todo::upsert($data, 'id'); $count++; } + // Collect hrefs/URLs returned by the CalDAV server so we can remove local // todos that belong to this calendar but were deleted on the server. $hrefs = array_values(array_filter(array_map(function ($t) { diff --git a/app/Http/Controllers/LineItemController.php b/app/Http/Controllers/LineItemController.php index 6f93efb..72c4c0f 100644 --- a/app/Http/Controllers/LineItemController.php +++ b/app/Http/Controllers/LineItemController.php @@ -14,7 +14,7 @@ public function index($invoiceId) $items = LineItem::with('unit') ->select('line_items.*') ->where('invoice_id', $invoiceId) - ->orderBy('position', 'desc') + ->orderBy('position', 'asc') ->get(); return $items->map(function ($item) { diff --git a/app/Http/Controllers/NoteController.php b/app/Http/Controllers/NoteController.php index 0e5d3a5..7079a1b 100644 --- a/app/Http/Controllers/NoteController.php +++ b/app/Http/Controllers/NoteController.php @@ -9,6 +9,15 @@ class NoteController extends Controller { + /** + * Display a listing of the resource + */ + public function index() + { + $notes = Note::with('user')->orderBy('created_at', 'desc')->get(); + return ApiDataTransformer::snakeToCamel($notes->toArray()); + } + /** * Display a listing of the resource. * @param Request $request @@ -16,7 +25,7 @@ class NoteController extends Controller * @param string $modelId The ID of the model * @return \Illuminate\Http\JsonResponse */ - public function index(Request $request, string $modelType, int $modelId) + public function notesForModel(Request $request, string $modelType, int $modelId) { $model = app("App\\Models\\" . $modelType)::findOrFail($modelId); diff --git a/app/Http/Controllers/PipelineController.php b/app/Http/Controllers/PipelineController.php index 78a768d..f251cf6 100644 --- a/app/Http/Controllers/PipelineController.php +++ b/app/Http/Controllers/PipelineController.php @@ -16,15 +16,38 @@ class PipelineController extends Controller { public function index() { - $lanes = PipelineLane::with(['items' => function ($q) { - $q->withCount(['notes' => function ($query) { - $query->where('notable_type', 'App\Models\PipelineItem'); - }])->orderBy('position'); - }])->orderBy('position')->get(); + $lanes = PipelineLane::with(['items' => function ($query) { + $query + ->withCount(['notes' => function ($query) { + $query->where('notable_type', 'App\Models\PipelineItem'); + }]) + ->withCount(['todos' => function ($query) { + $query->where('todoable_type', 'App\Models\PipelineItem') + ->whereNotIn('status', ['completed', 'COMPLETED']); + }]) + ->with(['todos' => function ($query) { + $query->where('todoable_type', 'App\Models\PipelineItem')->orderBy('due_date', 'asc'); + }]) + ->orderBy('position') + ; + }]) + ->orderBy('position') + ->get(); - return $lanes->map(function ($lane) { - return ApiDataTransformer::snakeToCamel($lane->toArray()); + $lanesArray = $lanes->map(function ($lane) { + $laneArray = $lane->toArray(); + + // Process items to add latest_todo_due_date and remove todos array + $laneArray['items'] = collect($laneArray['items'])->map(function ($item) { + $item['next_todo_due_date'] = $item['todos'][0]['due_date'] ?? null; + unset($item['todos']); // Remove the todos array + return $item; + })->toArray(); + + return $laneArray; }); + + return ApiDataTransformer::snakeToCamel($lanesArray->toArray()); } public function show() diff --git a/app/Http/Controllers/PipelineItemController.php b/app/Http/Controllers/PipelineItemController.php index 8543b1e..d4101fb 100644 --- a/app/Http/Controllers/PipelineItemController.php +++ b/app/Http/Controllers/PipelineItemController.php @@ -5,6 +5,7 @@ use App\Models\PipelineItem; use App\Support\ApiDataTransformer; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class PipelineItemController extends Controller { @@ -13,11 +14,24 @@ class PipelineItemController extends Controller */ public function index() { - $pipelineItems = PipelineItem::withCount(['notes' => function ($query) { - $query->where('notable_type', 'App\Models\PipelineItem'); - }])->orderBy('position')->get(); + $pipelineItems = PipelineItem + ::withCount(['notes' => function ($query) { + $query->where('notable_type', 'App\Models\PipelineItem'); + }]) + ->withCount(['todos' => function ($query) { + $query->where('todoable_type', 'App\Models\PipelineItem'); + }]) + ->orderBy('position') + ->get(); - return ApiDataTransformer::snakeToCamel($pipelineItems->toArray()); + $pipelineItemsArray = $pipelineItems->map(function ($item) { + $itemArray = $item->toArray(); + $itemArray['next_todo_due_date'] = $item->todos->first()?->due_date?->toISOString() ?? null; + unset($itemArray['todos']); // Remove the todos array + return $itemArray; + }); + + return ApiDataTransformer::snakeToCamel($pipelineItemsArray->toArray()); } /** @@ -25,9 +39,12 @@ public function index() */ public function single(int $id) { - $pipelineItem = PipelineItem::withCount(['notes' => function ($query) { - $query->where('notable_type', 'App\Models\PipelineItem'); - }])->orderBy('position')->findOrFail($id); + $pipelineItem = PipelineItem + ::with(['notes' => function ($query) { + $query->where('notable_type', 'App\Models\PipelineItem')->orderBy('created_at', 'desc'); + }])->with(['todos' => function ($query) { + $query->where('todoable_type', 'App\Models\PipelineItem')->orderBy('due_date', 'asc'); + }])->orderBy('position')->findOrFail($id); return ApiDataTransformer::snakeToCamel($pipelineItem->toArray()); } @@ -38,11 +55,11 @@ public function single(int $id) public function store(Request $request) { $validatedData = $request->validate([ - 'pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id', + 'pipelineLaneId' => 'required|integer|exists:pipeline_lanes,id', 'title' => 'required|string', 'position' => 'required|integer|min:0', - 'expected_revenue' => 'nullable|numeric', - 'due_date' => 'nullable|date', + 'expectedRevenue' => 'nullable|numeric', + 'dueDate' => 'nullable|date', 'description' => 'nullable|string', ]); @@ -54,20 +71,31 @@ public function store(Request $request) /** * Update the specified resource in storage. */ - public function update(Request $request, PipelineItem $pipelineItem) + public function update(Request $request, int $id) { $validatedData = $request->validate([ - 'pipeline_lane_id' => 'sometimes|integer|exists:pipeline_lanes,id', + 'pipelineLaneId' => 'sometimes|integer|exists:pipeline_lanes,id', 'title' => 'sometimes|string', 'position' => 'sometimes|integer|min:0', - 'expected_revenue' => 'nullable|numeric', - 'due_date' => 'nullable|date', + 'expectedRevenue' => 'nullable|numeric', + 'dueDate' => 'nullable|date', 'description' => 'nullable|string', ]); - $pipelineItem->update($validatedData); + $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); - return ApiDataTransformer::snakeToCamel($pipelineItem->toArray()); + DB::beginTransaction(); + + try { + $pipelineItem = PipelineItem::findOrFail($id); + $snakeCaseData = ApiDataTransformer::camelToSnake($validatedData); + $pipelineItem->update($snakeCaseData); + DB::commit(); + return response()->noContent(); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => 'Failed to update pipeline item', 'message' => $e->getMessage()], 500); + } } /** diff --git a/app/Http/Controllers/TodoController.php b/app/Http/Controllers/TodoController.php index 94c08a8..6563e76 100644 --- a/app/Http/Controllers/TodoController.php +++ b/app/Http/Controllers/TodoController.php @@ -15,7 +15,29 @@ public function index() return ApiDataTransformer::snakeToCamel($todos->toArray()); } - public function show(string $id) + /** + * Display a listing of the resource. + * @param Request $request + * @param string $modelType The type of the model (e.g., 'Customer', 'Invoice') + * @param string $modelId The ID of the model + * @return \Illuminate\Http\JsonResponse + */ + public function todosForModel(Request $request, string $modelType, int $modelId) + { + $model = app("App\\Models\\" . $modelType)::findOrFail($modelId); + + // Load all todos of the model with the user relationship + $todos = $model->todos()->with('type')->orderBy('created_at', 'desc')->get(); + + // Transformiere die Daten in camelCase + $notesArray = $todos->map(function ($todo) { + return ApiDataTransformer::snakeToCamel($todo->toArray()); + }); + + return response()->json($notesArray); + } + + public function single(string $id) { return Todo::with('type')->findOrFail($id); } @@ -35,6 +57,8 @@ public function store(Request $request) 'status' => 'nullable|string', 'parent' => 'nullable|string', 'object' => 'nullable|string', + 'todoable_id' => 'nullable|integer', + 'todoable_type' => 'nullable|string', ]); $data['id'] = $data['id'] ?? (string) Str::uuid(); @@ -42,6 +66,19 @@ public function store(Request $request) $data['created_at'] = $data['created_at'] ?? $now; $data['last_modified'] = $now; + // Set the title with the todoable title if todoable_id and todoable_type are set + if (isset($data['todoable_id']) && isset($data['todoable_type'])) { + $modelName = str_replace('App\\Models\\', '', $data['todoable_type']); + $modelClass = 'App\\Models\\' . $modelName; + + if (class_exists($modelClass)) { + $todoable = $modelClass::find($data['todoable_id']); + if ($todoable) { + $data['title'] = '[' . $todoable->title . '] ' . $data['title']; + } + } + } + $todo = Todo::create($data); return response()->json($todo, 201); @@ -63,10 +100,25 @@ public function update(Request $request, string $id) 'status' => 'nullable|string', 'parent' => 'nullable|string', 'object' => 'nullable|string', + 'todoable_id' => 'nullable|integer', + 'todoable_type' => 'nullable|string', ]); $data['last_modified'] = now(); + // Set the title with the todoable title if todoable_id and todoable_type are set + if (isset($data['todoable_id']) && isset($data['todoable_type'])) { + $modelName = str_replace('App\\Models\\', '', $data['todoable_type']); + $modelClass = 'App\\Models\\' . $modelName; + + if (class_exists($modelClass)) { + $todoable = $modelClass::find($data['todoable_id']); + if ($todoable) { + $data['title'] = '[' . $todoable->title . '] ' . $data['title']; + } + } + } + $todo->update($data); return response()->json($todo); diff --git a/app/Models/PipelineItem.php b/app/Models/PipelineItem.php index c6fed1f..bb9db92 100644 --- a/app/Models/PipelineItem.php +++ b/app/Models/PipelineItem.php @@ -39,4 +39,12 @@ public function notes() { return $this->morphMany(Note::class, 'notable'); } + + /** + * Get the todos + */ + public function todos() + { + return $this->morphMany(Todo::class, 'todoable'); + } } diff --git a/app/Models/Todo.php b/app/Models/Todo.php index 1eea1f2..7033e8d 100644 --- a/app/Models/Todo.php +++ b/app/Models/Todo.php @@ -31,6 +31,8 @@ class Todo extends Model 'last_modified', 'parent', 'object', + 'todoable_id', + 'todoable_type' ]; protected $casts = [ @@ -53,4 +55,4 @@ public function children() { return $this->hasMany(self::class, 'parent'); } -} \ No newline at end of file +} diff --git a/database/migrations/2025_11_28_000000_create_todo_table.php b/database/migrations/2025_11_28_000000_create_todo_table.php index b51ff72..eb18e4a 100644 --- a/database/migrations/2025_11_28_000000_create_todo_table.php +++ b/database/migrations/2025_11_28_000000_create_todo_table.php @@ -22,6 +22,9 @@ public function up(): void $table->timestamp('last_modified')->nullable(); // iCal LAST-MODIFIED $table->string('parent')->nullable()->index(); // RELATED-TO (parent UID) $table->string('object')->nullable(); // RELATED-TO (object reference) + $table->unsignedBigInteger('todoable_id'); + $table->string('todoable_type'); + $table->index(['id', 'todoable_id', 'todoable_type']); }); } diff --git a/resources/css/app.css b/resources/css/app.css index 16abbec..b6e34d2 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -131,7 +131,14 @@ @layer utilities { letter-spacing: 0.006em; } - @media (min-width: 1281px) { body, html { font-size: 16px; } } + @media (min-width: 1281px) { + + body, + html { + font-size: 16px; + } + } + /* @media (min-width: 1921px) { body, html { font-size: 18px; } } */ /* Fluid scaling */ @@ -170,10 +177,10 @@ :root { --accent: var(--color-zinc-100); --accent-foreground: hsl(0 0% 9%); --destructive: var(--color-red-500); - --destructive-foreground: hsl(0 0% 98%); + --destructive-foreground: var(--color-red-50); --success: var(--color-lime-400); --success-foreground: var(--color-foreground); - --warning: var(--color-amber-300); + --warning: var(--color-amber-400); --warning-foreground: var(--color-amber-900); --action: var(--color-blue-500); --action-foreground: var(--color-white); @@ -187,7 +194,8 @@ :root { --chart-5: hsl(27 87% 67%); --radius: 0.5rem; --main-background: var(--color-zinc-50); - --sidebar-background: oklch(95.5% 0.003 286.35);; + --sidebar-background: oklch(95.5% 0.003 286.35); + ; --sidebar-foreground: var(--foreground); --sidebar-icon: var(--color-zinc-500); --sidebar-primary: hsl(0 0% 10%); @@ -225,9 +233,9 @@ .dark { --destructive-foreground: var(--color-red-100); --success: var(--color-lime-700); --success-foreground: var(--color-lime-200); - --warning: var(--color-amber-900); - --warning-foreground: var(--color-amber-400); - --action: var(--color-blue-600); + --warning: var(--color-amber-400); + --warning-foreground: var(--color-amber-900); + --action: var(--color-blue-400); --action-foreground: var(--color-blue-200); --border: var(--color-neutral-700); --input: var(--color-neutral-700); @@ -269,6 +277,83 @@ @layer base { .lucide { stroke-width: 1.666; } + + .content { + h1 { + @apply text-xl font-bold; + } + + h2 { + @apply text-lg font-bold; + } + + h3, + h4, + h5, + h6 { + @apply text-md font-bold; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + @apply mb-4; + } + + h1:not(:first-child), + h2:not(:first-child), + h3:not(:first-child), + h4:not(:first-child), + h5:not(:first-child), + h6:not(:first-child) { + @apply mt-8; + } + + a { + @apply text-action hover:underline; + @apply cursor-pointer; + } + + ol, ul { + @apply my-4 ml-6; + } + ol { + @apply list-decimal list-inside; + } + + ul { + @apply list-disc list-inside; + } + + ol p, ul p { + @apply inline; + } + + p:not(:last-child) { + margin-bottom: calc(var(--spacing) * 1.333); + } + + article:not(:last-child) { + margin-bottom: calc(var(--spacing) * 3); + } + + article:not(:last-child) .note-content { + padding-bottom: calc(var(--spacing) * 3); + } + + blockquote { + margin: calc(var(--spacing) * 4) 0; + padding: calc(var(--spacing) * 4) calc(var(--spacing) * 6); + color: var(--color-muted-foreground); + background-color: var(--color-muted); + border-radius: var(--radius-lg); + border-left: 4px solid var(--color-border); + box-shadow: var(--shadow-md); + } + } } @layer components { diff --git a/resources/js/components/EditorDialog/EditorDialog.vue b/resources/js/components/EditorDialog/EditorDialog.vue index 4ab29c5..6ec81d8 100644 --- a/resources/js/components/EditorDialog/EditorDialog.vue +++ b/resources/js/components/EditorDialog/EditorDialog.vue @@ -79,7 +79,7 @@ const cancel = (event: Event | null) => {
-
+