Two month of work

This commit is contained in:
2026-02-17 10:35:03 +01:00
parent 0ffbeeedff
commit d9fd3d1ccb
158 changed files with 5637 additions and 1512 deletions
@@ -33,6 +33,31 @@ public function handle(CaldavService $service)
Todo::upsert($todo->attributesToArray(), '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) {
return $t->url ?? null;
}, $todos)));
// Remove local todos that have a URL in this calendar but weren't returned
// by the server (i.e. they were deleted remotely). Scope deletion by
// calendar prefix to avoid touching other calendars.
$calendarId = $service->getCalendarId();
$username = config('caldav.username') ?? '';
$calendarPrefix = 'calendars/' . $username . '/' . $calendarId;
if ($calendarId && $username) {
Todo::whereNotNull('url')
->where('url', 'like', '%' . $calendarPrefix . "%")
->whereNotIn('url', $hrefs ?: [''])
->delete();
}
// Remove old todos
Todo::where('status', 'COMPLETED')
->where('last_modified', '<', now()->subDays(30)) // TODO: get from settings
->where('due_date', '<', now()->subDays(30))
->delete();
Log::info("Synced " . count($todos) . " todos.");
$this->info("Synced " . count($todos) . " todos.");
+21 -31
View File
@@ -11,14 +11,14 @@ class NoteController extends Controller
{
/**
* 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 index(Request $request, $modelId)
public function index(Request $request, string $modelType, int $modelId)
{
$modelType = $request->route()->parameter('modelType');
if (!$modelType) {
$modelType = $request->route()->getAction('modelType');
}
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
$model = app("App\\Models\\" . $modelType)::findOrFail($modelId);
// Lade alle Notizen des Modells mit der Benutzerbeziehung
$notes = $model->notes()->with('user')->orderBy('created_at', 'desc')->get();
@@ -34,24 +34,30 @@ public function index(Request $request, $modelId)
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, $modelId)
public function store(Request $request)
{
$modelType = $request->route()->parameter('modelType');
if (!$modelType) {
$modelType = $request->route()->getAction('modelType');
}
$validatedData = $request->validate([
'userId' => 'required|integer|exists:users,id',
'text' => 'required|string',
'userId' => 'required|integer'
'noteableId' => 'required|integer',
'noteableType' => 'required|string',
'createdAt' => 'sometimes|date|nullable'
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
$note = new Note($validatedData);
$note->user_id = $validatedData['userId'];
$model = app("App\\Models\\" . $snakeCaseData['noteable_type'])::findOrFail($snakeCaseData['noteable_id']);
// Create a new Note instance
$note = new Note($snakeCaseData);
// Set the created_at field if it is provided
if (isset($snakeCaseData['created_at'])) {
$note->created_at = $snakeCaseData['created_at'];
}
$model->notes()->save($note);
return response()->json($this->single($note->id), 201);
@@ -64,22 +70,6 @@ public static function single($id)
}
/**
* Display the specified resource.
*/
public function show(Note $note)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Note $note)
{
//
}
/**
* Update the specified resource in storage.
*/
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\PipelineLane;
use App\Models\PipelineItem;
use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Inertia\Inertia;
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();
return $lanes->map(function ($lane) {
return ApiDataTransformer::snakeToCamel($lane->toArray());
});
}
public function show()
{
return Inertia::render('Pipeline', [
'pipeline' => $this->index(),
]);
}
/**
* Update positions in bulk. Expect array of objects with camelCase keys: id, stage, position
*/
public function updatePositions(Request $request)
{
$payload = $request->all();
if (!is_array($payload)) {
return response()->json(['message' => 'Invalid payload'], 422);
}
$items = array_map(function ($item) {
return ApiDataTransformer::camelToSnake((array)$item);
}, $payload);
$validator = Validator::make(['items' => $items], [
'items.*.id' => 'required|integer|exists:pipeline_items,id',
'items.*.pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id',
'items.*.title' => 'string',
'items.*.position' => 'required|integer|min:0',
'items.*.expected_revenue' => 'decimal',
'items.*.description' => 'string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
DB::beginTransaction();
try {
foreach ($items as $it) {
PipelineItem::where('id', $it['id'])->update([
'pipeline_lane_id' => $it['pipeline_lane_id'],
'position' => $it['position'],
]);
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to update pipeline positions: ' . $e->getMessage());
return response()->json(['message' => 'Failed to update positions'], 500);
}
return response()->json(['status' => 'ok']);
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers;
use App\Models\PipelineItem;
use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
class PipelineItemController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$pipelineItems = PipelineItem::withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position')->get();
return ApiDataTransformer::snakeToCamel($pipelineItems->toArray());
}
/**
* Display a listing of the resource.
*/
public function single(int $id)
{
$pipelineItem = PipelineItem::withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position')->findOrFail($id);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validatedData = $request->validate([
'pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id',
'title' => 'required|string',
'position' => 'required|integer|min:0',
'expected_revenue' => 'nullable|numeric',
'due_date' => 'nullable|date',
'description' => 'nullable|string',
]);
$pipelineItem = PipelineItem::create($validatedData);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, PipelineItem $pipelineItem)
{
$validatedData = $request->validate([
'pipeline_lane_id' => 'sometimes|integer|exists:pipeline_lanes,id',
'title' => 'sometimes|string',
'position' => 'sometimes|integer|min:0',
'expected_revenue' => 'nullable|numeric',
'due_date' => 'nullable|date',
'description' => 'nullable|string',
]);
$pipelineItem->update($validatedData);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
}
/**
* Remove the specified resource from storage.
*/
public function delete(int $id)
{
PipelineItem::findOrFail($id)->delete();
return response()->noContent();
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use Inertia\Inertia;
use App\Models\Timesheet;
use Illuminate\Http\Request;
use App\Support\ApiDataTransformer;
class TimesheetController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$timesheets = Timesheet::with(['entries' => function ($query) {
$query->selectRaw('
timesheet_id,
SUM(hours) as total_hours,
SUM(CASE WHEN billed = 1 THEN hours ELSE 0 END) as hours_billed,
MIN(date) as earliest_date,
MAX(date) as latest_date
')
->groupBy('timesheet_id');
}])->get();
// Sort timesheets by the updated_at timestamp of the latest entry
$timesheets = $timesheets->sortByDesc(function ($timesheet) {
$latestEntry = $timesheet->entries()->latest('updated_at')->first();
return $latestEntry ? $latestEntry->updated_at : $timesheet->updated_at;
})->values();
$snakeCaseData = $timesheets->map(function ($timesheet) {
$entryStats = $timesheet->entries->first();
return [
'id' => $timesheet->id,
'title' => $timesheet->title,
'total_hours' => $entryStats->total_hours ?? 0,
'hours_billed' => $entryStats->hours_billed ?? 0,
'earliest_date' => $entryStats->earliest_date ?? null,
'latest_date' => $entryStats->latest_date ?? null,
'created_at' => $timesheet->created_at->toISOString(),
'updated_at' => $timesheet->updated_at->toISOString(),
];
});
return ApiDataTransformer::snakeToCamel($snakeCaseData->toArray());
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
]);
$snakeCaseData = Timesheet::create($validated);
return ApiDataTransformer::snakeToCamel($snakeCaseData->toArray());
}
/**
* Display the specified resource.
*/
public function show(Timesheet $timesheet)
{
return Inertia::render('Timesheets', [
'timesheetData' => $this->index()
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Timesheet $timesheet)
{
$validatedData = $request->validate([
'title' => 'required|string|max:255',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$timesheet->update($snakeCaseData);
return $timesheet;
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Timesheet $timesheet)
{
$timesheet->delete();
return response()->noContent();
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Models\TimesheetEntry;
use App\Models\Timesheet;
use Illuminate\Http\Request;
use App\Support\ApiDataTransformer;
class TimesheetEntryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$entries = TimesheetEntry::with(['timesheet', 'user'])->orderBy('date', 'asc')->get();
return $entries->map(function ($entry) {
return ApiDataTransformer::snakeToCamel($entry->toArray());
});
}
/**
* Get all entries for a specific timesheet
*/
public function getEntriesForTimesheet(Timesheet $timesheet)
{
$entries = $timesheet->entries()->with('user')->orderBy('date', 'asc')->get();
return $entries->map(function ($entry) {
return ApiDataTransformer::snakeToCamel($entry->toArray());
});
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validatedData = $request->validate([
'timesheetId' => 'required|exists:timesheets,id',
'date' => 'required|date',
'userId' => 'required|exists:users,id',
'description' => 'nullable|string',
'hours' => 'required|numeric',
'billed' => 'sometimes|boolean',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$entry = TimesheetEntry::create($snakeCaseData);
$entry = TimesheetEntry::with(['timesheet', 'user'])->find($entry->id);
return ApiDataTransformer::snakeToCamel($entry->toArray());
}
/**
* Display the specified resource.
*/
public function show(TimesheetEntry $timesheetEntry)
{
$entry = $timesheetEntry->load(['timesheet', 'user']);
return ApiDataTransformer::snakeToCamel($entry->toArray());
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, TimesheetEntry $timesheetEntry)
{
$validatedData = $request->validate([
'timesheetId' => 'sometimes|exists:timesheets,id',
'date' => 'sometimes|date',
'userId' => 'sometimes|exists:users,id',
'description' => 'nullable|string',
'hours' => 'sometimes|numeric',
'billed' => 'sometimes|boolean',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$timesheetEntry->update($snakeCaseData);
return ApiDataTransformer::snakeToCamel($timesheetEntry->toArray());
}
/**
* Remove the specified resource from storage.
*/
public function destroy(TimesheetEntry $timesheetEntry)
{
$timesheetEntry->delete();
return response()->noContent();
}
public function toggleBilled(TimesheetEntry $timesheetEntry)
{
$timesheetEntry = $timesheetEntry->update(['billed' => !$timesheetEntry->billed]);
return ApiDataTransformer::snakeToCamel($timesheetEntry->toArray());
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class TodoController extends Controller
{
public function index()
{
$todos = Todo::with('type')->get();
$todos = Todo::with('type')->orderBy('due_date')->get();
return ApiDataTransformer::snakeToCamel($todos->toArray());
}
+1 -1
View File
@@ -2,8 +2,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Customer extends Model
{
+6
View File
@@ -20,6 +20,12 @@ class LineItem extends Model
'price',
];
protected $casts = [
'is_section' => 'boolean',
'quantity' => 'integer',
'price' => 'decimal:2',
];
public function invoice()
{
return $this->belongsTo(Invoice::class);
+2
View File
@@ -22,6 +22,8 @@ public function user()
return $this->belongsTo(User::class);
}
// Polymorphic relationship to the notable model (e.g., Lead, Contact, PipelineItem, etc.)
// https://laravel.com/docs/12.x/eloquent-relationships#one-to-many-polymorphic-relations
public function notable()
{
return $this->morphTo();
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PipelineItem extends Model
{
use HasFactory;
protected $table = 'pipeline_items';
protected $fillable = [
'pipeline_lane_id',
'title',
'position',
'expected_revenue',
'due_date',
'description'
];
protected $casts = [
'next_action' => 'date',
'expected_revenue' => 'decimal:2',
'actions' => 'integer',
'position' => 'integer',
];
public function pipelineLane()
{
return $this->belongsTo(PipelineLane::class);
}
/**
* Get the notes
*/
public function notes()
{
return $this->morphMany(Note::class, 'notable');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class PipelineLane extends Model
{
use HasFactory;
protected $fillable = [
'title',
'position',
];
protected $casts = [
'position' => 'integer',
];
/**
* Get the pipeline items for the lane
*/
public function items()
{
return $this->hasMany(PipelineItem::class);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Timesheet extends Model
{
protected $fillable = ['title'];
public function entries(): HasMany
{
return $this->hasMany(TimesheetEntry::class);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TimesheetEntry extends Model
{
protected $fillable = [
'timesheet_id',
'date',
'user_id',
'description',
'hours',
'billed'
];
protected $casts = [
'date' => 'datetime',
'billed' => 'boolean',
'hours' => 'float'
];
public function timesheet(): BelongsTo
{
return $this->belongsTo(Timesheet::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+4
View File
@@ -34,8 +34,12 @@ class User extends Authenticatable
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
];
/**
* Get the attributes that should be cast.
*