Compare commits

14 Commits

38 changed files with 2392 additions and 1609 deletions
+46 -8
View File
@@ -37,35 +37,73 @@ ### Prerequisites
- Node.js (for frontend assets) - Node.js (for frontend assets)
- SQLite/MySQL/PostgreSQL - SQLite/MySQL/PostgreSQL
### Apache config
```apache
<VirtualHost *:80>
ServerName crm.tooloop.de
Redirect permanent / https://crm.tooloop.de/
</VirtualHost>
<VirtualHost *:443>
ServerName crm.tooloop.de
DocumentRoot /var/www/html/caramel/public/
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/cloud.tooloop.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/cloud.tooloop.de/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
<Directory /var/www/html/caramel/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/caramel_error.log
CustomLog ${APACHE_LOG_DIR}/caramel-access.log combined
</VirtualHost>
```
### Steps ### Steps
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone http://code.tooloop.de/Tooloop/Caramel-CRM.git git clone http://code.tooloop.de/Tooloop/Caramel-CRM.**git**
cd Caramel-CRM cd Caramel-CRM
``` ```
2. Create empty database: 2. Create empty database:
```bash ```bash
touch database/database.sqlite touch database/database.sqlite
``` ```
3. Set permissions
```bash
sudo chown -R www-data:www-data /var/www/html/caramel/storage/ bootstrap/cache/
sudo chmod g+rw /var/www/html/caramel/storage/ /var/www/html/caramel/bootstrap/cache/
```
3. Install dependencies: 3. Install dependencies:
```bash ```bash
composer update
composer install composer install
npm install npm install
``` ```
4. Set up the environment: 4. Set up the environment:
```bash ```bash
cp .env.example .env cp .env.example .env
php artisan key\:generate sudo php artisan key\:generate
``` ```
5. Run migrations and seeders: 5. Create a symbolic link for public storage
```bash ```bash
php artisan migrate --seed sudo php artisan storage:link
``` ```
6. Build frontend assets: 6. Run migrations and seeders:
```bash ```bash
npm run dev sudo php artisan migrate --seed
``` ```
7. Start the development server: 7. Build frontend assets:
```bash
npm run build
```
8. Start the development server:
```bash ```bash
php artisan serve php artisan serve
``` ```
@@ -83,7 +121,7 @@ ### Running the scheduler
You can choose between one of three updating methods by editing the `app.cron_method` value in the database table `settings`: You can choose between one of three updating methods by editing the `app.cron_method` value in the database table `settings`:
| Value | Description | | Value | Description |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cron` | If you can, configure a CRON job in the OS | | `cron` | If you can, configure a CRON job in the OS |
| `webcron` | Some hosting prividers provide a feature to call a HTTP-request in regular intervals. | | `webcron` | Some hosting prividers provide a feature to call a HTTP-request in regular intervals. |
| `request` | If both of the above are not possible in your setup, you can choose `request`. The web UI will then regularly call the `/webcron` route from your browser. | | `request` | If both of the above are not possible in your setup, you can choose `request`. The web UI will then regularly call the `/webcron` route from your browser. |
+49 -3
View File
@@ -7,6 +7,7 @@
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Services\CaldavService; use App\Services\CaldavService;
use App\Models\Todo; use App\Models\Todo;
use App\Models\PipelineItem;
class CaldavSyncCommand extends Command class CaldavSyncCommand extends Command
{ {
@@ -23,16 +24,60 @@ public function handle(CaldavService $service)
} }
Cache::put($cacheKey, true, 300); Cache::put($cacheKey, true, 300);
Log::info('Running CalDAV sync'); Log::info('Synchronizing Todo itmes with CalDAV server');
$this->info('Starting CalDAV sync'); $this->info('Synchronizing Todo itmes with CalDAV server');
$todos = $service->getTodos(); $todos = $service->getTodos();
$count = 0; $count = 0;
foreach ($todos as $todo) { 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++; $count++;
} }
// Collect hrefs/URLs returned by the CalDAV server so we can remove local // 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. // todos that belong to this calendar but were deleted on the server.
$hrefs = array_values(array_filter(array_map(function ($t) { $hrefs = array_values(array_filter(array_map(function ($t) {
@@ -61,6 +106,7 @@ public function handle(CaldavService $service)
Log::info("Synced " . count($todos) . " todos."); Log::info("Synced " . count($todos) . " todos.");
$this->info("Synced " . count($todos) . " todos."); $this->info("Synced " . count($todos) . " todos.");
return 0; return 0;
} }
} }
@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
use App\Models\Invoice;
class CheckInvoiceDueDatesCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'invoices:check-due';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check invoices for due dates (runs synchronously)';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('Check invoice due dates');
Log::info('Check invoice due dates');
try {
$today = Carbon::today();
// Find invoices with payment_status 'issued' and due_date <= today
$invoices = Invoice::where('payment_status', 'issued')
->whereDate('due_date', '<=', $today)
->get();
foreach ($invoices as $invoice) {
$invoice->update(['payment_status' => 'due']);
$this->info("Updated invoice {$invoice->nr} to 'due' status");
}
$this->info("Checked {$invoices->count()} invoices for due dates.");
} catch (\Exception $e) {
$this->error("Error in CheckInvoiceDueDatesJob: " . $e->getMessage());
}
return 0;
}
}
+9
View File
@@ -14,6 +14,7 @@ class Kernel extends ConsoleKernel
*/ */
protected $commands = [ protected $commands = [
\App\Console\Commands\CaldavSyncCommand::class, \App\Console\Commands\CaldavSyncCommand::class,
\App\Console\Commands\CheckInvoiceDueDatesCommand::class,
]; ];
/** /**
@@ -21,6 +22,14 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule): void protected function schedule(Schedule $schedule): void
{ {
$schedule->command('caldav:sync')
->everyMinute()
->withoutOverlapping();
// Run synchronously on schedule to support environments without queue workers
$schedule->command('invoices:check-due')
->dailyAt('3:00')
->withoutOverlapping();
} }
/** /**
+1 -1
View File
@@ -14,7 +14,7 @@ public function index($invoiceId)
$items = LineItem::with('unit') $items = LineItem::with('unit')
->select('line_items.*') ->select('line_items.*')
->where('invoice_id', $invoiceId) ->where('invoice_id', $invoiceId)
->orderBy('position', 'desc') ->orderBy('position', 'asc')
->get(); ->get();
return $items->map(function ($item) { return $items->map(function ($item) {
+10 -1
View File
@@ -9,6 +9,15 @@
class NoteController extends Controller 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. * Display a listing of the resource.
* @param Request $request * @param Request $request
@@ -16,7 +25,7 @@ class NoteController extends Controller
* @param string $modelId The ID of the model * @param string $modelId The ID of the model
* @return \Illuminate\Http\JsonResponse * @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); $model = app("App\\Models\\" . $modelType)::findOrFail($modelId);
+34 -6
View File
@@ -16,15 +16,43 @@ class PipelineController extends Controller
{ {
public function index() public function index()
{ {
$lanes = PipelineLane::with(['items' => function ($q) { $lanes = PipelineLane::with(['items' => function ($query) {
$q->withCount(['notes' => function ($query) { $query
->withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem'); $query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position'); }])
}])->orderBy('position')->get(); ->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) { $lanesArray = $lanes->map(function ($lane) {
return ApiDataTransformer::snakeToCamel($lane->toArray()); $laneArray = $lane->toArray();
// Process items to add latest_todo_due_date and remove todos array
$laneArray['items'] = collect($laneArray['items'])->map(function ($item) {
// Find the first todo with status != 'COMPLETED'
$nextTodo = collect($item['todos'])->first(function ($todo) {
return strtolower($todo['status'] ?? '') !== 'completed';
}); });
$item['next_todo_due_date'] = $nextTodo['due_date'] ?? null;
unset($item['todos']); // Remove the todos array
return $item;
})->toArray();
return $laneArray;
});
return ApiDataTransformer::snakeToCamel($lanesArray->toArray());
} }
public function show() public function show()
+45 -14
View File
@@ -5,6 +5,7 @@
use App\Models\PipelineItem; use App\Models\PipelineItem;
use App\Support\ApiDataTransformer; use App\Support\ApiDataTransformer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class PipelineItemController extends Controller class PipelineItemController extends Controller
{ {
@@ -13,11 +14,26 @@ class PipelineItemController extends Controller
*/ */
public function index() public function index()
{ {
$pipelineItems = PipelineItem::withCount(['notes' => function ($query) { $pipelineItems = PipelineItem
::withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem'); $query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position')->get(); }])
->withCount(['todos' => function ($query) {
$query
->where('todoable_type', 'App\Models\PipelineItem')
->whereNotIn('status', ['completed', 'COMPLETED']);
}])
->orderBy('position')
->get();
return ApiDataTransformer::snakeToCamel($pipelineItems->toArray()); $pipelineItemsArray = $pipelineItems->map(function ($item) {
$itemArray = $item->toArray();
$itemArray['next_todo_due_date'] = $item->todos()->where('status', '!=', 'COMPLETED')->first()?->due_date?->toISOString() ?? null;
unset($itemArray['todos']); // Remove the todos array
return $itemArray;
});
return ApiDataTransformer::snakeToCamel($pipelineItemsArray->toArray());
} }
/** /**
@@ -25,8 +41,12 @@ public function index()
*/ */
public function single(int $id) public function single(int $id)
{ {
$pipelineItem = PipelineItem::withCount(['notes' => function ($query) { $pipelineItem = PipelineItem
$query->where('notable_type', 'App\Models\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); }])->orderBy('position')->findOrFail($id);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray()); return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
@@ -38,11 +58,11 @@ public function single(int $id)
public function store(Request $request) public function store(Request $request)
{ {
$validatedData = $request->validate([ $validatedData = $request->validate([
'pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id', 'pipelineLaneId' => 'required|integer|exists:pipeline_lanes,id',
'title' => 'required|string', 'title' => 'required|string',
'position' => 'required|integer|min:0', 'position' => 'required|integer|min:0',
'expected_revenue' => 'nullable|numeric', 'expectedRevenue' => 'nullable|numeric',
'due_date' => 'nullable|date', 'dueDate' => 'nullable|date',
'description' => 'nullable|string', 'description' => 'nullable|string',
]); ]);
@@ -54,20 +74,31 @@ public function store(Request $request)
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, PipelineItem $pipelineItem) public function update(Request $request, int $id)
{ {
$validatedData = $request->validate([ $validatedData = $request->validate([
'pipeline_lane_id' => 'sometimes|integer|exists:pipeline_lanes,id', 'pipelineLaneId' => 'sometimes|integer|exists:pipeline_lanes,id',
'title' => 'sometimes|string', 'title' => 'sometimes|string',
'position' => 'sometimes|integer|min:0', 'position' => 'sometimes|integer|min:0',
'expected_revenue' => 'nullable|numeric', 'expectedRevenue' => 'nullable|numeric',
'due_date' => 'nullable|date', 'dueDate' => 'nullable|date',
'description' => 'nullable|string', '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);
}
} }
/** /**
+53 -1
View File
@@ -15,7 +15,29 @@ public function index()
return ApiDataTransformer::snakeToCamel($todos->toArray()); 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); return Todo::with('type')->findOrFail($id);
} }
@@ -35,6 +57,8 @@ public function store(Request $request)
'status' => 'nullable|string', 'status' => 'nullable|string',
'parent' => 'nullable|string', 'parent' => 'nullable|string',
'object' => 'nullable|string', 'object' => 'nullable|string',
'todoable_id' => 'nullable|integer',
'todoable_type' => 'nullable|string',
]); ]);
$data['id'] = $data['id'] ?? (string) Str::uuid(); $data['id'] = $data['id'] ?? (string) Str::uuid();
@@ -42,6 +66,19 @@ public function store(Request $request)
$data['created_at'] = $data['created_at'] ?? $now; $data['created_at'] = $data['created_at'] ?? $now;
$data['last_modified'] = $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); $todo = Todo::create($data);
return response()->json($todo, 201); return response()->json($todo, 201);
@@ -63,10 +100,25 @@ public function update(Request $request, string $id)
'status' => 'nullable|string', 'status' => 'nullable|string',
'parent' => 'nullable|string', 'parent' => 'nullable|string',
'object' => 'nullable|string', 'object' => 'nullable|string',
'todoable_id' => 'nullable|integer',
'todoable_type' => 'nullable|string',
]); ]);
$data['last_modified'] = 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->update($data); $todo->update($data);
return response()->json($todo); return response()->json($todo);
-41
View File
@@ -1,41 +0,0 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Models\Invoice;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class CheckInvoiceDueDatesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
try {
$today = Carbon::today();
// Find invoices with payment_status 'issued' and due_date <= today
$invoices = Invoice::where('payment_status', 'issued')
->whereDate('due_date', '<=', $today)
->get();
foreach ($invoices as $invoice) {
$invoice->update(['payment_status' => 'due']);
Log::info("Updated invoice {$invoice->nr} to 'due' status");
}
Log::info("Checked {$invoices->count()} invoices for due dates.");
} catch (\Exception $e) {
Log::error("Error in CheckInvoiceDueDatesJob: " . $e->getMessage());
}
}
}
+8
View File
@@ -39,4 +39,12 @@ public function notes()
{ {
return $this->morphMany(Note::class, 'notable'); return $this->morphMany(Note::class, 'notable');
} }
/**
* Get the todos
*/
public function todos()
{
return $this->morphMany(Todo::class, 'todoable');
}
} }
+2
View File
@@ -31,6 +31,8 @@ class Todo extends Model
'last_modified', 'last_modified',
'parent', 'parent',
'object', 'object',
'todoable_id',
'todoable_type'
]; ];
protected $casts = [ protected $casts = [
-11
View File
@@ -16,16 +16,5 @@ class EventServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
parent::boot(); parent::boot();
// // TODO: read where to put these or ask in the forums
// // it seems to work here, but where is the apropriate place?
// // Kernel::schedule did not work
Schedule::command('caldav:sync')
->everyMinute()
->withoutOverlapping();
Schedule::job(new CheckInvoiceDueDatesJob())
->daily()
->withoutOverlapping();
} }
} }
+4 -4
View File
@@ -159,10 +159,10 @@ public function getTodos()
$todo->type_id = 2; $todo->type_id = 2;
$todo->url = $href; $todo->url = $href;
if (isset($vcalendar->VTODO->DUE)) $todo->due_date = $vcalendar->VTODO->DUE->getDateTime(); if (isset($vcalendar->VTODO->DUE)) $todo->due_date = $vcalendar->VTODO->DUE->getDateTime();
if(isset($vcalendar->VTODO->RRULE)) $todo->recurring = $vcalendar->VTODO->RRULE->getValue(); if (isset($vcalendar->VTODO->RRULE)) $todo->recurring = $vcalendar->VTODO->RRULE->getValue();
if(isset($vcalendar->VTODO->PRIORITY)) $todo->priority = $vcalendar->VTODO->PRIORITY->getValue(); if (isset($vcalendar->VTODO->PRIORITY)) $todo->priority = $vcalendar->VTODO->PRIORITY->getValue();
if(isset($vcalendar->VTODO->STATUS)) $todo->status = $vcalendar->VTODO->STATUS->getValue(); if (isset($vcalendar->VTODO->STATUS)) $todo->status = $vcalendar->VTODO->STATUS->getValue();
if(isset($vcalendar->VTODO->{'RELATED-TO'})) $todo->parent = $vcalendar->VTODO->{'RELATED-TO'}->getValue(); if (isset($vcalendar->VTODO->{'RELATED-TO'})) $todo->parent = $vcalendar->VTODO->{'RELATED-TO'}->getValue();
$todo->created_at = $vcalendar->VTODO->CREATED->getDateTime(); $todo->created_at = $vcalendar->VTODO->CREATED->getDateTime();
$todo->last_modified = $vcalendar->VTODO->{'LAST-MODIFIED'}->getDateTime(); $todo->last_modified = $vcalendar->VTODO->{'LAST-MODIFIED'}->getDateTime();
+10 -2
View File
@@ -7,7 +7,7 @@
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__)) $app = Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__ . '/../routes/web.php', web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php', commands: __DIR__ . '/../routes/console.php',
@@ -26,4 +26,12 @@
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //
})->create(); })->withKernels()->create();
$app->singleton(
\Illuminate\Contracts\Console\Kernel::class,
\App\Console\Kernel::class
);
return $app;
Executable
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
# Konfiguration
USERNAME="vollstock"
PASSWORD="bBm1AxSkE-tiw-_LXRicPQ"
TIMEOUT=10
# Farben für die Ausgabe
BLACK=$(tput setaf 0)
RED=$(tput setaf 1)
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
LIME_YELLOW=$(tput setaf 190)
POWDER_BLUE=$(tput setaf 153)
BLUE=$(tput setaf 4)
MAGENTA=$(tput setaf 5)
CYAN=$(tput setaf 6)
WHITE=$(tput setaf 7)
GRAY=$(tput setaf 240)
BRIGHT=$(tput bold)
NORMAL=$(tput sgr0)
BLINK=$(tput blink)
REVERSE=$(tput smso)
UNDERLINE=$(tput smul)
# Funktion zum Formatieren der Ausgabe
format_output() {
local label=$1
local result=$2
local success=$3
local output=$4
local max_width=72
# Kürze das Label, falls es zu lang ist
if [ ${#label} -gt $((max_width - 20)) ]; then
label="${label:0:$((max_width - 20))}"
fi
# Berechne die Anzahl der Punkte, die zwischen Label und Ergebnis eingefügt werden sollen
dots_count=$((max_width - ${#label} - ${#result} - 4)) # 4 für die Klammern und Leerzeichen
# Erstelle die Punkte-Zeichenkette
dots=$(printf '%*s' "$dots_count" | tr ' ' '.')
# Bestimme die Farbe für das Ergebnis
if [ "$success" = true ]; then
result_color=$GREEN
elif [ "$success" = false ]; then
result_color=$RED
else
result_color=$YELLOW
fi
# Ausgabe formatieren
printf "%s%s%s %s%s%s [%s%s%s]\n\n" "$MAGENTA" "$label" "$NORMAL" "$GRAY" "$dots" "$NORMAL" "$result_color" "$result" "$NORMAL"
printf "%s\n\n" "$output"
}
# Funktion zum Ausführen des Curl-Befehls und Formatieren der Ausgabe
run_test() {
label=$1
url=$2
method=$3
depth=$4
local curl_output
curl_output=$(curl -s -w "\nHTTP Status: %{http_code}" \
--max-time "$TIMEOUT" \
-u "$USERNAME:$PASSWORD" \
-H "Depth: $depth; Content-Type: application/xml" \
-X $method \
"$url")
status_code=$(echo "$curl_output" | tail -n 1 | awk '{print $3}')
if [ "$status_code" -gt 400 ]; then
success=false
else
success=true
fi
# Ausgabe formatieren
format_output "$label" "$status_code" "$success" "$curl_output"
}
# Hauptfunktion
main() {
echo ''
# | label | url | method | depth
# |--------------|---------------------------------------------------------------------------------------------------|-------------| --------
run_test "Base URL" "https://cloud.tooloop.de/remote.php/dav/" "GET" 0
run_test "Principal" "https://cloud.tooloop.de/remote.php/dav/principals/users/vollstock" "PROPFIND" 1
run_test "Calendars" "https://cloud.tooloop.de/remote.php/dav/calendars/vollstock" "PROPFIND" 1
run_test "Calendar Info" "https://cloud.tooloop.de/remote.php/dav/calendars/vollstock/migrated-6E79619D-F037-413E-9FC2-46EA716E5CB3" "PROPFIND" 1
echo ''
}
# Skript ausführen
main
Generated
+825 -666
View File
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,9 @@ public function up(): void
$table->timestamp('last_modified')->nullable(); // iCal LAST-MODIFIED $table->timestamp('last_modified')->nullable(); // iCal LAST-MODIFIED
$table->string('parent')->nullable()->index(); // RELATED-TO (parent UID) $table->string('parent')->nullable()->index(); // RELATED-TO (parent UID)
$table->string('object')->nullable(); // RELATED-TO (object reference) $table->string('object')->nullable(); // RELATED-TO (object reference)
$table->unsignedBigInteger('todoable_id');
$table->string('todoable_type');
$table->index(['id', 'todoable_id', 'todoable_type']);
}); });
} }
+644 -617
View File
File diff suppressed because it is too large Load Diff
+92 -7
View File
@@ -131,7 +131,14 @@ @layer utilities {
letter-spacing: 0.006em; 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; } } */ /* @media (min-width: 1921px) { body, html { font-size: 18px; } } */
/* Fluid scaling */ /* Fluid scaling */
@@ -170,10 +177,10 @@ :root {
--accent: var(--color-zinc-100); --accent: var(--color-zinc-100);
--accent-foreground: hsl(0 0% 9%); --accent-foreground: hsl(0 0% 9%);
--destructive: var(--color-red-500); --destructive: var(--color-red-500);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: var(--color-red-50);
--success: var(--color-lime-400); --success: var(--color-lime-400);
--success-foreground: var(--color-foreground); --success-foreground: var(--color-foreground);
--warning: var(--color-amber-300); --warning: var(--color-amber-400);
--warning-foreground: var(--color-amber-900); --warning-foreground: var(--color-amber-900);
--action: var(--color-blue-500); --action: var(--color-blue-500);
--action-foreground: var(--color-white); --action-foreground: var(--color-white);
@@ -187,7 +194,8 @@ :root {
--chart-5: hsl(27 87% 67%); --chart-5: hsl(27 87% 67%);
--radius: 0.5rem; --radius: 0.5rem;
--main-background: var(--color-zinc-50); --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-foreground: var(--foreground);
--sidebar-icon: var(--color-zinc-500); --sidebar-icon: var(--color-zinc-500);
--sidebar-primary: hsl(0 0% 10%); --sidebar-primary: hsl(0 0% 10%);
@@ -225,9 +233,9 @@ .dark {
--destructive-foreground: var(--color-red-100); --destructive-foreground: var(--color-red-100);
--success: var(--color-lime-700); --success: var(--color-lime-700);
--success-foreground: var(--color-lime-200); --success-foreground: var(--color-lime-200);
--warning: var(--color-amber-900); --warning: var(--color-amber-400);
--warning-foreground: var(--color-amber-400); --warning-foreground: var(--color-amber-900);
--action: var(--color-blue-600); --action: var(--color-blue-400);
--action-foreground: var(--color-blue-200); --action-foreground: var(--color-blue-200);
--border: var(--color-neutral-700); --border: var(--color-neutral-700);
--input: var(--color-neutral-700); --input: var(--color-neutral-700);
@@ -269,6 +277,83 @@ @layer base {
.lucide { .lucide {
stroke-width: 1.666; 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 { @layer components {
@@ -79,7 +79,7 @@ const cancel = (event: Event | null) => {
</DialogHeader> </DialogHeader>
<div class="flex flex-row"> <div class="flex flex-row">
<div class="p-4 md:p-6 lg:p-12 pt-0! grow"> <div class="p-4 md:p-6 lg:p-12 pt-0! grow overflow-y-auto">
<slot name="content"></slot> <slot name="content"></slot>
</div> </div>
<aside class="w-120 p-4 md:p-6 lg:p-12 pt-0! flex flex-col gap-4" v-if="$slots.sidebar"> <aside class="w-120 p-4 md:p-6 lg:p-12 pt-0! flex flex-col gap-4" v-if="$slots.sidebar">
+25 -93
View File
@@ -1,18 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Editor, EditorContent, } from '@tiptap/vue-3' import { onBeforeUnmount, onMounted, ref, watch, h, render } from 'vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import TextEditorMenu from './TextEditorMenu.vue';
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { ButtonGroup, ButtonGroupSeparator } from './ui/button-group';
import { Button } from './ui/crm-button';
import { Bold, Code2, Heading, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, List, ListOrdered, Pilcrow, Redo2, Strikethrough, Undo2 } from 'lucide-vue-next';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import Separator from './ui/separator/Separator.vue';
const props = defineProps<{ const props = defineProps<{
modelValue?: string | null | undefined modelValue?: string | null | undefined
placeholder?: string | null
}>() }>()
const editor = ref<Editor>() const editor = ref<Editor>()
const menu = ref()
const getContent = (): string => editor.value?.getHTML() || '' const getContent = (): string => editor.value?.getHTML() || ''
const isFocused = (): boolean => editor.value?.isFocused || false const isFocused = (): boolean => editor.value?.isFocused || false
@@ -34,10 +32,21 @@ onMounted(() => {
if (!editor.value) return if (!editor.value) return
emit('update:modelValue', editor.value.getHTML()) emit('update:modelValue', editor.value.getHTML())
}, },
onFocus: () => {
// menu.value = h(TextEditorMenu, {
// editor: editor.value
// });
// render(menu.value, document.body)
},
onBlur: () => { onBlur: () => {
if (!editor.value) return if (!editor.value) return
emit('change:modelValue', editor.value.getHTML()) emit('change:modelValue', editor.value.getHTML())
}
// if (menu.value) {
// render(null, document.body, menu.value.el)
// menu.value = null
// }
},
}) })
}) })
@@ -49,92 +58,15 @@ onBeforeUnmount(() => {
<template> <template>
<div v-bind:spellcheck="editor?.isFocused">
<TextEditorMenu :editor="editor" />
<!-- Editor --> <div class="relative">
<div> <EditorContent :editor="editor" class="editor mb-8 content" />
<!-- Placeholder -->
<!-- Menu --> <div class="absolute top-0 italic text-muted-foreground pointer-events-none"
<ButtonGroup class="editor-menu shadow border rounded-md overflow-clip z-1 bg-background"> :class="{ 'hidden': !editor?.isEmpty }">{{ props.placeholder || 'Beschreibung' }}</div>
<Button @click="editor?.chain().focus().undo().run()" :disabled="!editor?.can().undo()" size="sm" </div>
variant="ghost">
<Undo2 />
</Button>
<Button @click="editor?.chain().focus().redo().run()" :disabled="!editor?.can().redo()" size="sm"
variant="ghost">
<redo2 />
</Button>
<ButtonGroupSeparator/>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="ghost">
<heading />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editor?.chain().focus().clearNodes().run()" size="sm" variant="ghost">
<pilcrow /> Absatz
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">
<heading1 /> Überschrift 1
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">
<heading2 /> Überschrift 2
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()">
<heading3 /> Überschrift 3
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 4 }).run()">
<heading4 /> Überschrift 4
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 5 }).run()">
<heading5 /> Überschrift 5
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 6 }).run()">
<heading6 /> Überschrift 6
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<dropdown-menu>
<dropdown-menu-trigger as-child>
<Button size="sm" variant="ghost">
<list />
</Button>
</dropdown-menu-trigger>
<dropdown-menu-content>
<dropdown-menu-item @click="editor?.chain().focus().toggleBulletList().run()">
<list /> Ungeordnete Liste
</dropdown-menu-item>
<dropdown-menu-item @click="editor?.chain().focus().toggleOrderedList().run()"><list-ordered />
Geordnete Liste</dropdown-menu-item>
</dropdown-menu-content>
</dropdown-menu>
<ButtonGroupSeparator/>
<Button @click="editor?.chain().focus().toggleBold().run()" :class="{ 'is-active': editor?.isActive('bold') }"
size="sm" variant="ghost">
<Bold />
</Button>
<Button @click="editor?.chain().focus().toggleItalic().run()" size="sm" variant="ghost">
<Italic />
</Button>
<Button @click="editor?.chain().focus().toggleStrike().run()" size="sm" variant="ghost">
<strikethrough />
</Button>
<Button @click="editor?.chain().focus().toggleCode().run()" size="sm" variant="ghost">
<code2 />
</Button>
</ButtonGroup>
<EditorContent :editor="editor" class="editor mb-8" />
<div class="absolute top-0.75 py-2 px-0.75 italic text-muted-foreground pointer-events-none"
:class="{ 'hidden': !editor?.isEmpty }">Beschreibung</div>
</div> </div>
</template> </template>
<style></style> <style></style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { Editor, } from '@tiptap/vue-3'
import { computed, onMounted, ref } from 'vue';
import { ButtonGroup, ButtonGroupSeparator } from './ui/button-group';
import { Button } from './ui/crm-button';
import { Bold, Code2, Heading, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, List, ListOrdered, Pilcrow, Redo2, Strikethrough, Undo2 } from 'lucide-vue-next';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
const props = defineProps<{
editor: Editor | undefined
}>()
const position = ref({ top: 0, left: 0 })
onMounted(() => {
position.value.left = props.editor?.options.element.getBoundingClientRect().x
position.value.top = props.editor?.options.element.getBoundingClientRect().y
})
const positionStyle = computed(() => {
return 'top: calc(' + position.value.top + 'px - var(--spacing) * 9); ' +
'left: ' + position.value.left + 'px;'
})
</script>
<template>
<ButtonGroup
class="editor-menu z-50 border shadow rounded-md overflow-clip bg-background pointer-events-auto"
:style="positionStyle">
<Button @click="editor?.chain().focus().undo().run()" :disabled="!editor?.can().undo()" size="sm"
variant="ghost">
<Undo2 />
</Button>
<Button @click="editor?.chain().focus().redo().run()" :disabled="!editor?.can().redo()" size="sm"
variant="ghost">
<redo2 />
</Button>
<ButtonGroupSeparator />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="ghost">
<heading />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editor?.chain().focus().clearNodes().run()" size="sm">
<pilcrow /> Absatz
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">
<heading1 /> Überschrift 1
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">
<heading2 /> Überschrift 2
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()">
<heading3 /> Überschrift 3
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 4 }).run()">
<heading4 /> Überschrift 4
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 5 }).run()">
<heading5 /> Überschrift 5
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 6 }).run()">
<heading6 /> Überschrift 6
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<dropdown-menu>
<dropdown-menu-trigger as-child>
<Button size="sm" variant="ghost">
<list />
</Button>
</dropdown-menu-trigger>
<dropdown-menu-content>
<dropdown-menu-item @click="editor?.chain().focus().toggleBulletList().run()">
<list /> Ungeordnete Liste
</dropdown-menu-item>
<dropdown-menu-item @click="editor?.chain().focus().toggleOrderedList().run()"><list-ordered />
Geordnete Liste</dropdown-menu-item>
</dropdown-menu-content>
</dropdown-menu>
<ButtonGroupSeparator />
<Button @click="editor?.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor?.isActive('bold') }" size="sm" variant="ghost">
<Bold />
</Button>
<Button @click="editor?.chain().focus().toggleItalic().run()" size="sm" variant="ghost">
<Italic />
</Button>
<Button @click="editor?.chain().focus().toggleStrike().run()" size="sm" variant="ghost">
<strikethrough />
</Button>
<Button @click="editor?.chain().focus().toggleCode().run()" size="sm" variant="ghost">
<code2 />
</Button>
</ButtonGroup>
</template>
+44 -12
View File
@@ -1,22 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from "vue" import { ref, computed, watch, onMounted } from "vue"
import { Todo } from "@/types"; import { Todo } from "@/types";
import { toLocalDate, toDuration, isToday, daysFromNow } from "@/lib/utils"; import { toLocalDate, toDuration, isToday, daysFromNow } from "@/lib/utils";
import { Mail, PhoneCall, ClipboardCheck } from "lucide-vue-next" import { Mail, PhoneCall, ClipboardCheck, Repeat } from "lucide-vue-next"
import { Badge } from '@/components/ui/crm-badge' import { Badge } from '@/components/ui/crm-badge'
const props = defineProps<{ const props = defineProps<{
modelValue?: Todo[] | null modelValue?: Todo[] | null
showCompleted?: boolean showCompleted?: boolean | false
showTodoable?: boolean | false
}>() }>()
const todos = ref<Todo[]>([]) const todos = ref<Todo[]>([])
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const showCompleted = ref(props.showCompleted)
watch(() => props.modelValue, value => { watch(() => props.modelValue, value => {
todos.value = value as Todo[]; todos.value = value as Todo[];
}) })
onMounted(() => {
if (props.modelValue) {
todos.value = props.modelValue as Todo[];
}
})
const groupedTodos = computed(() => { const groupedTodos = computed(() => {
const groups: Record<string, Todo[]> = {}; const groups: Record<string, Todo[]> = {};
@@ -31,6 +37,11 @@ const groupedTodos = computed(() => {
if (!groups['today']) groups['today'] = [] if (!groups['today']) groups['today'] = []
groups['today'].push(todo) groups['today'].push(todo)
} }
// tomorrow
else if (daysFromNow(dueDate) === 1) {
if (!groups['tomorrow']) groups['tomorrow'] = []
groups['tomorrow'].push(todo)
}
// overdue // overdue
else if (daysFromNow(dueDate) < 0) { else if (daysFromNow(dueDate) < 0) {
if (!groups['overdue']) groups['overdue'] = [] if (!groups['overdue']) groups['overdue'] = []
@@ -48,6 +59,19 @@ const groupedTodos = computed(() => {
return groups return groups
}) })
const groupNameForKey = (key: string) => {
if (key === 'today') return 'Heute'
if (key === 'tomorrow') return 'Morgen'
if (key === 'overdue') return 'Überfällig'
return key
}
const todosEmpty = (todos: Todo[]) => {
if (todos.length === 0) return true
if (!props.showCompleted && !todos.some(todo => todo.status?.toLowerCase() !== 'completed')) return true
return false
}
const todoTitle = (title: string) => { const todoTitle = (title: string) => {
// If there's no title, return empty string // If there's no title, return empty string
if (!title) return ''; if (!title) return '';
@@ -75,18 +99,22 @@ const todoBadge = (title: string) => {
const shouldDisplay = (todo: Todo) => { const shouldDisplay = (todo: Todo) => {
if (todo.status?.toLowerCase() !== 'completed') return true if (todo.status?.toLowerCase() !== 'completed') return true
return showCompleted.value; // && moment(todo.dueDate).isSameOrAfter(moment(new Date()), 'day') return props.showCompleted;
} }
</script> </script>
<template> <template>
<div v-if="todos" v-for="(todos, groupKey) in groupedTodos" :key="groupKey"> <div v-if="todos" v-for="(todos, groupKey) in groupedTodos" :key="groupKey">
<div v-if="!todosEmpty(todos)">
<!-- Group header --> <!-- Group header -->
<h3 class="mt-4 mb-2 text-sm text-muted-foreground" <h3 class="mt-4 mb-2 text-sm text-muted-foreground" :class="{
:class="{ 'text-destructive! font-bold': groupKey === 'overdue', 'text-foreground! font-bold': groupKey === 'today' }"> 'text-destructive! font-bold': groupKey === 'overdue',
{{ groupKey === 'today' ? 'Heute' : groupKey === 'overdue' ? 'Verspätet' : groupKey }} 'text-warning! font-bold': groupKey === 'today'
}">
{{ groupNameForKey(groupKey) }}
</h3> </h3>
<hr> <hr>
<ul> <ul>
@@ -118,13 +146,16 @@ const shouldDisplay = (todo: Todo) => {
<!-- Date --> <!-- Date -->
<div class="text-xs text-muted-foreground flex gap-3 items-center mt-1"> <div class="text-xs text-muted-foreground flex gap-3 items-center mt-1">
<Badge v-if="todoBadge(todo.title)" variant="outline">{{ todoBadge(todo.title) <Badge v-if="props.showTodoable && todoBadge(todo.title)" variant="outline">{{
todoBadge(todo.title)
}} }}
</Badge> </Badge>
<span v-if="todo.dueDate" <span v-if="todo.dueDate" :class="{
:class="{ 'text-destructive font-bold': todo.status?.toLowerCase() != 'completed' && daysFromNow(todo.dueDate) < 0 }"> 'text-destructive! font-bold': groupKey === 'overdue',
'text-warning! font-bold': groupKey === 'today'
}">
{{ toDuration(todo.dueDate) }}</span> {{ toDuration(todo.dueDate) }}</span>
<!-- <Repeat v-if="todo.recurring" stroke-width="2" :size="14" /> --> <Repeat v-if="todo.recurring" stroke-width="2" :size="14" />
</div> </div>
</div> </div>
@@ -138,4 +169,5 @@ const shouldDisplay = (todo: Todo) => {
</li> </li>
</ul> </ul>
</div> </div>
</div>
</template> </template>
@@ -105,7 +105,7 @@ const calcTaxes = (amount: number) => {
<template> <template>
<Table class="relative document-table"> <Table class="relative document-table">
<TableHeader class="sticky top-0"> <TableHeader class="sticky -top-4 md:-top-6 lg:-top-8">
<TableRow> <TableRow>
<TableHead class="w-1/100 lg:w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5">Nr.</TableHead> <TableHead class="w-1/100 lg:w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5">Nr.</TableHead>
<TableHead class="w-1/100 lg:w-1/20 text-center">Status</TableHead> <TableHead class="w-1/100 lg:w-1/20 text-center">Status</TableHead>
@@ -193,7 +193,7 @@ const calcTaxes = (amount: number) => {
</TableRow> </TableRow>
<TableRow v-if="totalNotIssued > 0"> <TableRow v-if="totalNotIssued > 0">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell> <TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell> <TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell> <TableCell colspan="1"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Nicht gestellt</TableCell> <TableCell class="text-right tabular-nums w-1/100 font-bold">Nicht gestellt</TableCell>
@@ -30,6 +30,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd'
import DialogClose from "../ui/dialog/DialogClose.vue" import DialogClose from "../ui/dialog/DialogClose.vue"
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue" import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue" import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
import TextEditor from "../TextEditor.vue"
const DEBUG = ref(false) const DEBUG = ref(false)
@@ -86,8 +87,8 @@ onMounted(async () => {
// Process each response // Process each response
customers.value = responses[0].data customers.value = responses[0].data
paymentTerms.value = responses[0].data paymentTerms.value = responses[1].data
units.value = responses[0].data units.value = responses[2].data
} catch (error) { } catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error)) toast.error('Fehler beim Laden der Daten', error || String(error))
@@ -161,7 +162,7 @@ watch(invoice,
return; return;
} }
// If no billing data is store in the invoice, generat ot from customer // If no billing data is store in the invoice, generate ot from customer
if (!newValue.billingData) { if (!newValue.billingData) {
newValue.billingData = { newValue.billingData = {
companyName: newValue.customer?.companyName || "", companyName: newValue.customer?.companyName || "",
@@ -176,7 +177,7 @@ watch(invoice,
contactSalutation: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].salutation : "", contactSalutation: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].salutation : "",
contactFirstName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].firstName : "", contactFirstName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].firstName : "",
contactLastName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].lastName : "", contactLastName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].lastName : "",
paymentTerms: newValue.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null, paymentTerms: newValue.customer?.paymentTerms || paymentTerms.value.length > 0 ? paymentTerms.value[2] : null,
} }
} }
@@ -395,6 +396,7 @@ const exportXml = function () {
const issueInvoice = function () { const issueInvoice = function () {
if (!invoice.value) return; if (!invoice.value) return;
invoice.value.paymentStatus = 'issued' invoice.value.paymentStatus = 'issued'
save()
} }
const deleteInvoice = function () { const deleteInvoice = function () {
@@ -546,13 +548,12 @@ const handleFileUpload = async (event: Event) => {
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-12"> <DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-12">
<div class="flex flex-col grow"> <div class="flex flex-col grow">
<DialogTitle class="text-primary-foreground font-bold text-left"> <DialogTitle class="text-primary font-bold text-left">
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<Input v-if="invoice" v-model="invoice.title" :id="'invoice-title'" <Input v-if="invoice" v-model="invoice.title" @update:model-value="isDirty = true"
class="text-foreground md:text-base text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" :id="'invoice-title'" class="" type="text" placeholder="Titel" />
type="text" placeholder="Titel" />
</DialogDescription> </DialogDescription>
</div> </div>
@@ -899,7 +900,10 @@ const handleFileUpload = async (event: Event) => {
</div> </div>
<div id="document-text" class="mt-6 md:mt-8 lg:mt-12"> <div id="document-text" class="mt-6 md:mt-8 lg:mt-12">
<GrowingTextarea v-model="invoice.text" placeholder="Anschreiben" <!-- <GrowingTextarea v-model="invoice.text" placeholder="Anschreiben"
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" /> -->
<TextEditor v-model="invoice.text" placeholder="Anschreiben"
@change:model-value="isDirty = true"
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" /> class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" />
</div> </div>
@@ -24,7 +24,7 @@ import { toast } from "vue-sonner";
import ButtonGroup from '../ui/button-group/ButtonGroup.vue'; import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
const DEBUG = ref(true) const DEBUG = ref(false)
const props = defineProps<{ const props = defineProps<{
isLoading?: boolean, isLoading?: boolean,
@@ -60,12 +60,12 @@ watch(() => props.lineItems, async (newLineItems) => {
updateFromParent.value = true updateFromParent.value = true
// Only update if the items actually changed // Only update if the items actually changed
if (JSON.stringify(items.value) !== JSON.stringify(newLineItems)) { // if (JSON.stringify(items.value) !== JSON.stringify(newLineItems)) {
items.value = (newLineItems ?? [])
} else {
console.log('already up to date, no change')
}
// items.value = (newLineItems ?? []) // items.value = (newLineItems ?? [])
// } else {
// console.log('already up to date, no change')
// }
items.value = (newLineItems ?? [])
// Reset flag after next tick // Reset flag after next tick
await nextTick() await nextTick()
@@ -90,6 +90,7 @@ watch(items, (newItems) => {
// Don't emit changes in loading // Don't emit changes in loading
if (props.isLoading || updateFromParent.value) return if (props.isLoading || updateFromParent.value) return
recalculatePositions()
console.log('emit update:lineItems') console.log('emit update:lineItems')
emit('update:lineItems', newItems) emit('update:lineItems', newItems)
}, { deep: true }) }, { deep: true })
@@ -144,6 +145,7 @@ const deleteItem = (lineItem: LineItem) => {
} }
const recalculatePositions = () => { const recalculatePositions = () => {
console.log('recalculatePositions')
for (let i = 0; i < items.value.length; i++) { for (let i = 0; i < items.value.length; i++) {
items.value[i].position = getNextPosition(i, items.value[i].isSection) items.value[i].position = getNextPosition(i, items.value[i].isSection)
} }
@@ -189,8 +191,7 @@ const recalculatePositions = () => {
<TableHead class="h-0 w-8"></TableHead> <TableHead class="h-0 w-8"></TableHead>
</TableRow> </TableRow>
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost" <draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost">
@end="recalculatePositions">
<template #item="{ element }"> <template #item="{ element }">
<TableRow v-if="element.isSection"> <TableRow v-if="element.isSection">
@@ -206,7 +207,6 @@ const recalculatePositions = () => {
class="font-light bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" /> class="font-light bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" />
</TableCell> </TableCell>
<!-- Buttons --> <!-- Buttons -->
<TableCell class="w-8 text-right px-1"> <TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)" <Button variant="ghost" size="sm" @click="deleteItem(element)"
@@ -13,7 +13,7 @@ export const badgeVariants = cva(
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-desctructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
warning: warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 dark:bg-warning/60", "border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 dark:bg-warning/60",
outline: outline:
+11 -3
View File
@@ -120,15 +120,23 @@ export function millisFromNow(value: string | Date): number {
} }
export function minutesFromNow(value: string | Date): number { export function minutesFromNow(value: string | Date): number {
return Math.round(millisFromNow(value) / (1000 * 60)) const mins = millisFromNow(value) / (1000 * 60);
return Math.sign(mins) * Math.floor(Math.abs(mins));
} }
export function hoursFromNow(value: string | Date): number { export function hoursFromNow(value: string | Date): number {
return Math.round(millisFromNow(value) / (1000 * 60 * 60)) const hrs = millisFromNow(value) / (1000 * 60 * 60);
return Math.sign(hrs) * Math.floor(Math.abs(hrs));
} }
export function daysFromNow(value: string | Date): number { export function daysFromNow(value: string | Date): number {
return Math.round(millisFromNow(value) / (1000 * 60 * 60 * 24)) const date = (typeof value === 'string') ? new Date(value) : value;
const today = new Date();
const utcDate = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
const utcToday = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
return Math.round((utcDate - utcToday) / (1000 * 60 * 60 * 24));
} }
export function isThisHour(value: Date | string): boolean { export function isThisHour(value: Date | string): boolean {
+6 -7
View File
@@ -1,21 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { onMounted, ref, computed } from "vue" import { onMounted, ref } from "vue"
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { Trophy, ArrowRight, UserCheck2, Repeat, ClipboardCheck, X, ChevronRight } from 'lucide-vue-next'; import { Trophy, UserCheck2, X, ChevronRight } from 'lucide-vue-next';
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'
import Button from '@/components/ui/crm-button/Button.vue'; import Button from '@/components/ui/crm-button/Button.vue';
import { invoices } from '@/routes'; import { invoices } from '@/routes';
import { toLocalDate, toRoundedCurrency } from '@/lib/utils' import { toRoundedCurrency } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/crm-badge'
import { Link, usePage } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import { toast } from "vue-sonner"; import { toast } from "vue-sonner";
import { Todo } from "@/types"; import { AppPageProps, Todo } from "@/types";
import Todos from '@/components/Todos.vue'; import Todos from '@/components/Todos.vue';
const salesStatistics = ref({ const salesStatistics = ref({
@@ -122,7 +121,7 @@ onMounted(async () => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Todos :modelValue="todos" :show-completed="showCompleted" /> <Todos :modelValue="todos" :show-completed="showCompleted" :show-todoable="true" />
</CardContent> </CardContent>
</Card> </Card>
@@ -163,7 +162,7 @@ onMounted(async () => {
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<div :style="'width: ' + (salesStatistics.paid / salesTarget * 100) + '%'" <div :style="'width: ' + (salesStatistics.paid / salesTarget * 100) + '%'"
class="bg-primary-foreground transition-width duration-500 ease-out"></div> class="bg-primary transition-width duration-500 ease-out"></div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<strong>Bezahlt</strong> <strong>Bezahlt</strong>
+49 -24
View File
@@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/crm-badge/'
import AppLayout from '@/layouts/AppLayout.vue' import AppLayout from '@/layouts/AppLayout.vue'
import { daysFromNow, isSoon, isToday, toCurrency, toDuration } from '@/lib/utils' import { daysFromNow, isSoon, isToday, toCurrency, toDuration } from '@/lib/utils'
import { Head } from '@inertiajs/vue3' import { Head } from '@inertiajs/vue3'
import { Calendar, ClipboardCheck, MessageCircle, CircleHelp, Plus, Trash2 } from 'lucide-vue-next' import { Calendar, ClipboardCheck, MessageCircle, CircleHelp, Plus, Trash2, Check, SquareCheckBig, MessageSquare } from 'lucide-vue-next'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import axios from 'axios' import axios from 'axios'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
@@ -18,9 +18,11 @@ import TextEditor from '@/components/TextEditor.vue'
import Todos from '@/components/Todos.vue'; import Todos from '@/components/Todos.vue';
import Notes from '@/components/Notes.vue' import Notes from '@/components/Notes.vue'
import NotesService from '@/services/NotesService' import NotesService from '@/services/NotesService'
import TodoService from '@/services/TodoService'
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue'; import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
import { alertStore } from '@/stores/alertStore' import { alertStore } from '@/stores/alertStore'
import PipelineService from '@/services/PipelineService' import PipelineService from '@/services/PipelineService'
import { cva } from "class-variance-authority"
interface Props { interface Props {
pipeline: PipelineLane[] pipeline: PipelineLane[]
@@ -42,7 +44,6 @@ const dragOptions = ref({
dragClass: "drag", // Class name for the dragging item dragClass: "drag", // Class name for the dragging item
}) })
const editorDialogOpen = ref(false) const editorDialogOpen = ref(false)
const todos = ref<Todo[]>([])
const alert = alertStore() const alert = alertStore()
const laneSums = computed(() => { const laneSums = computed(() => {
@@ -123,31 +124,37 @@ const getLaneIdFromEl = (el: any): number | null => {
} }
const cardClasses = (item: PipelineItem): string => { const cardClasses = (item: PipelineItem): string => {
// Due date // Destructive
if (item.dueDate && daysFromNow(item.dueDate) < 0) return "border-l-4 border-destructive" if (item.dueDate && daysFromNow(item.dueDate) < 0 ||
else if (item.dueDate && isSoon(item.dueDate)) return "border-l-4 border-warning" item.nextTodoDueDate && daysFromNow(item.nextTodoDueDate) < 0
) return "border-l-4 border-destructive"
// Warning
if (item.dueDate && isSoon(item.dueDate) ||
item.nextTodoDueDate && isSoon(item.nextTodoDueDate)
) return "border-l-4 border-warning"
return "" return ""
} }
const badgeVariant = (item: PipelineItem): "default" | "secondary" | "destructive" | "warning" | "outline" | null | undefined => { const badgeVariant = (date: string | null): "default" | "secondary" | "destructive" | "warning" | "outline" | null | undefined => {
// Due date // Due date
if (item.dueDate && daysFromNow(item.dueDate) < 0) return "destructive" if (date && daysFromNow(date) < 0) return "destructive"
else if (item.dueDate && isSoon(item.dueDate)) return "warning" else if (date && isSoon(date)) return "warning"
return "secondary" return "secondary"
} }
const editItem = async (item: PipelineItem) => { const editItem = async (item: PipelineItem) => {
// Load Todos // Load todos lazily
// try { if (item.id !== 0 && (item.todos === undefined || item.todos.length === 0)) {
// let response = await axios.get('/api/todos') TodoService.getTodosForModel('PipelineItem', item.id).then(todos => {
// todos.value = response.data if (todos) item.todos = todos
// } catch (error) { })
// toast.error('Fehler beim Laden der Daten', { description: (error as AxiosError).message }) }
// }
// Load notes lazily // Load notes lazily
if (item.id !== 0 && (item.notes === undefined || item.notes.length === 0)) { if (item.id !== 0 && (item.notes === undefined || item.notes.length === 0)) {
NotesService.getAllNotes('PipelineItem', item.id).then(notes => { NotesService.getNotesForModel('PipelineItem', item.id).then(notes => {
if (notes) item.notes = notes if (notes) item.notes = notes
}) })
} }
@@ -177,6 +184,14 @@ const deleteItem = (item: PipelineItem | undefined) => {
} }
) )
} }
const saveItem = (item: PipelineItem | undefined) => {
if (!item) return
if (item.id === 0) {
PipelineService.createPipelineItem(item);
} else {
PipelineService.updatePipelineItem(item);
}
}
</script> </script>
@@ -185,6 +200,7 @@ const deleteItem = (item: PipelineItem | undefined) => {
<Head title="Vertriebspipeline" /> <Head title="Vertriebspipeline" />
<AppLayout title="Vertriebspipeline"> <AppLayout title="Vertriebspipeline">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<!-- Header --> <!-- Header -->
@@ -263,22 +279,30 @@ const deleteItem = (item: PipelineItem | undefined) => {
toCurrency(item.expectedRevenue) }}</p> toCurrency(item.expectedRevenue) }}</p>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" v-if="item.actions">
<ClipboardCheck /> {{ item.actions }}
</Badge>
<Badge v-if="item.dueDate" :variant="badgeVariant(item)"> <Badge v-if="item.dueDate" :variant="badgeVariant(item.dueDate)">
<Calendar /> <Calendar />
<span v-if="isToday(item.dueDate)">Heute</span> <span v-if="isToday(item.dueDate)">Heute</span>
<span v-else>{{ toDuration(item.dueDate) }}</span> <span v-else>{{ toDuration(item.dueDate) }}</span>
</Badge> </Badge>
<Badge v-if="item.todos && item.todos.length > 0"
:variant="badgeVariant(item.todos[item.todos.length - 1]?.dueDate || null)">
<SquareCheckBig /> {{item.todos.filter(todo => todo.status.toLowerCase() !==
'completed').length}}
</Badge>
<Badge v-else-if="item.todosCount > 0"
:variant="badgeVariant(item.nextTodoDueDate)">
<SquareCheckBig /> {{ item.todosCount }}
</Badge>
<Badge variant="secondary" v-if="item.notes && item.notes.length > 0"> <Badge variant="secondary" v-if="item.notes && item.notes.length > 0">
<MessageCircle /> {{ item.notes.length }} <MessageSquare /> {{ item.notes.length }}
</Badge> </Badge>
<Badge variant="secondary" v-else-if="item.notesCount > 0"> <Badge variant="secondary" v-else-if="item.notesCount > 0">
<MessageCircle /> {{ item.notesCount }} <MessageSquare /> {{ item.notesCount }}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -317,7 +341,8 @@ const deleteItem = (item: PipelineItem | undefined) => {
</template> </template>
<template v-slot:content> <template v-slot:content>
<TextEditor :model-value="selectedItem?.description" @change:model-value="console.log" <TextEditor :model-value="selectedItem?.description"
@change:model-value="value => { selectedItem!.description = value; saveItem(selectedItem) }"
ref="description-editor" /> ref="description-editor" />
<Notes v-if="selectedItem" title="Protokoll" :notableId="selectedItem.id" notableType="PipelineItem" <Notes v-if="selectedItem" title="Protokoll" :notableId="selectedItem.id" notableType="PipelineItem"
:modelValue="selectedItem.notes" /> :modelValue="selectedItem.notes" />
@@ -326,7 +351,7 @@ const deleteItem = (item: PipelineItem | undefined) => {
<template v-slot:sidebar> <template v-slot:sidebar>
<NumberInput label="Erwarteter Umsatz" :modelValue="selectedItem?.expectedRevenue as number" suffix=" " <NumberInput label="Erwarteter Umsatz" :modelValue="selectedItem?.expectedRevenue as number" suffix=" "
@update:model-value="console.log" /> @update:model-value="console.log" />
<Todos :modelValue="todos" :show-completed="false" /> <Todos v-if="selectedItem" title="Aufgaben" :modelValue="selectedItem.todos" :show-completed="false" />
</template> </template>
</EditorDialog> </EditorDialog>
+7 -4
View File
@@ -159,11 +159,14 @@ const createInvoice = async () => {
let billedEntries: TimesheetEntry[] = [] let billedEntries: TimesheetEntry[] = []
let invoice: Invoice = newInvoice() let invoice: Invoice = newInvoice()
invoice.customer = null invoice.customer = null
invoice.customerId = null
invoice.billingData = null invoice.billingData = null
timesheets.value[selectedTimesheet.value].entries?.forEach((entry => {
timesheets.value[selectedTimesheet.value].entries?.forEach(((entry, i) => {
if (entry.billed) return if (entry.billed) return
let lineItem: LineItem = newLineItem(false) let lineItem: LineItem = newLineItem(false)
lineItem.position = i + 1
lineItem.title = toLocalDate(entry.date) lineItem.title = toLocalDate(entry.date)
lineItem.description = entry.description || '' lineItem.description = entry.description || ''
lineItem.quantity = entry.hours lineItem.quantity = entry.hours
@@ -249,7 +252,7 @@ const createInvoice = async () => {
<TableHead class="w-1/6">Datum</TableHead> <TableHead class="w-1/6">Datum</TableHead>
<TableHead class="w-1/6">Mitarbeiter</TableHead> <TableHead class="w-1/6">Mitarbeiter</TableHead>
<TableHead class="w-1">Beschreibung</TableHead> <TableHead class="w-1">Beschreibung</TableHead>
<TableHead class="w-1/100 text-right">Dauer</TableHead> <TableHead class="w-1/10 text-right">Dauer</TableHead>
<TableHead class="w-1/100 print:hidden"></TableHead> <TableHead class="w-1/100 print:hidden"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -301,7 +304,7 @@ const createInvoice = async () => {
<Plus class="text-muted-foreground" /> Zeile einfügen <Plus class="text-muted-foreground" /> Zeile einfügen
</Button> </Button>
</TableCell> </TableCell>
<TableCell class="text-right" v-if="selectedTimesheet >= 0">{{ <TableCell class="text-right whitespace-nowrap" v-if="selectedTimesheet >= 0">{{
hourFormatter.format(timesheetSummary(timesheets[selectedTimesheet]).totalHours ?? 0) }} h hourFormatter.format(timesheetSummary(timesheets[selectedTimesheet]).totalHours ?? 0) }} h
</TableCell> </TableCell>
<TableCell class="print:hidden"></TableCell> <TableCell class="print:hidden"></TableCell>
@@ -339,7 +342,7 @@ const createInvoice = async () => {
<Progress <Progress
v-if="timesheetSummary(timesheet).hoursBilled && timesheetSummary(timesheet).hoursBilled > 0 && timesheetSummary(timesheet).totalHours" v-if="timesheetSummary(timesheet).hoursBilled && timesheetSummary(timesheet).hoursBilled > 0 && timesheetSummary(timesheet).totalHours"
:model-value="(timesheetSummary(timesheet).hoursBilled / timesheetSummary(timesheet).totalHours) * 100" :model-value="(timesheetSummary(timesheet).hoursBilled / timesheetSummary(timesheet).totalHours) * 100"
class="h-1 w-10 text-muted-foreground" /> class="h-1 w-10 text-success" />
</div> </div>
</li> </li>
</ul> </ul>
+4 -4
View File
@@ -1,5 +1,5 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { Note, PipelineItem } from '@/types'; import { Note } from '@/types';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
const API_URL = '/api/notes'; const API_URL = '/api/notes';
@@ -7,11 +7,11 @@ const API_URL = '/api/notes';
export default { export default {
/** /**
* Retrieves all notes * Retrieves all notes
* @returns Promise<Note<PipelineItem>[] | null> * @returns Promise<Note[] | null>
*/ */
async getAllNotes(modelType: string, notableId: number): Promise<Note<PipelineItem>[] | null> { async getNotesForModel(modelType: string, notableId: number): Promise<Note[] | null> {
try { try {
const response = await axios.get<Note<PipelineItem>[]>(`${API_URL}/${modelType}/${notableId}`) const response = await axios.get<Note[]>(`${API_URL}/${modelType}/${notableId}`)
return response.data; return response.data;
} catch (error) { } catch (error) {
toast.error('Fehler beim Laden der Notizen', { description: (error as AxiosError).message }) toast.error('Fehler beim Laden der Notizen', { description: (error as AxiosError).message })
+32
View File
@@ -21,4 +21,36 @@ export default {
return false; return false;
} }
}, },
/**
* Creates a new pipeline item
* @param item
* @returns
*/
async createPipelineItem(item: PipelineItem): Promise<PipelineItem | null> {
try {
const response = await axios.post(ITEMS_API_URL, item);
return response.data;
} catch (error) {
toast.error('Fehler beim Speichern des Vorgangs', { description: (error as AxiosError).message })
console.error(error)
return null;
}
},
/**
* Updates an existing pipeline item
* @param item
* @returns
*/
async updatePipelineItem(item: PipelineItem): Promise<PipelineItem | null> {
try {
const response = await axios.put(ITEMS_API_URL + '/' + item.id, item);
return response.data;
} catch (error) {
toast.error('Fehler beim Speichern des Vorgangs', { description: (error as AxiosError).message })
console.error(error)
return null;
}
}
}; };
+25
View File
@@ -0,0 +1,25 @@
import axios, { AxiosError } from 'axios';
import { Todo } from '@/types';
import { toast } from 'vue-sonner';
const API_URL = '/api/todos';
export default {
/**
* Retrieves all notes
* @returns Promise<Todo[] | null>
*/
async getTodosForModel(modelType: string, notableId: number): Promise<Todo[] | null> {
try {
const response = await axios.get<Todo[]>(`${API_URL}/${modelType}/${notableId}`)
return response.data;
} catch (error) {
toast.error('Fehler beim Laden der Aufgaben', { description: (error as AxiosError).message })
console.error(error)
}
return null
},
};
+12 -2
View File
@@ -175,6 +175,8 @@ export function newTodoType(): TodoType {
} }
} }
export type TodoableType = 'PipelineItem'
export interface Todo { export interface Todo {
id: string; id: string;
etag: string | null; etag: string | null;
@@ -193,6 +195,8 @@ export interface Todo {
parentTodo?: Todo | null; parentTodo?: Todo | null;
children?: Todo[]; children?: Todo[];
object: string | null; object: string | null;
todoableId: number;
todoableType: TodoableType;
} }
export function newTodo(): Todo { export function newTodo(): Todo {
@@ -213,7 +217,9 @@ export function newTodo(): Todo {
parent: null, parent: null,
parentTodo: null, parentTodo: null,
children: [], children: [],
object: null object: null,
todoableId: number,
todoableType: PipelineItem
} }
} }
@@ -415,7 +421,9 @@ export interface PipelineItem {
expectedRevenue: number | null; expectedRevenue: number | null;
dueDate: string | null; dueDate: string | null;
description: string | null; description: string | null;
notes?: Note<PipelineItem>[]; notes?: Note[];
todos?: Todo[];
nextTodoDueDate?: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -429,6 +437,8 @@ export function newPipelineItem(): PipelineItem {
dueDate: null, dueDate: null,
description: null, description: null,
notes: [], notes: [],
todos: [],
nextTodoDueDate: null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
+2 -2
View File
@@ -133,9 +133,9 @@
<h1>Rechnung</h1> <h1>Rechnung</h1>
<h2>{{ $invoice['title'] }}</h2> <h2>{{ $invoice['title'] }}</h2>
@if($invoice['text']) @if($invoice['text'] && $invoice['text'] !== "<p></p>")
<div class="text"> <div class="text">
{!! nl2br(e($invoice['text'])) !!} {!! $invoice['text'] !!}
</div> </div>
@endif @endif
+3 -2
View File
@@ -11,7 +11,6 @@
use App\Http\Controllers\TodoController; use App\Http\Controllers\TodoController;
use App\Http\Controllers\UnitController; use App\Http\Controllers\UnitController;
use App\Mail\OrderConfirmation; use App\Mail\OrderConfirmation;
use App\Services\CaldavService;
use App\Http\Controllers\TimesheetController; use App\Http\Controllers\TimesheetController;
use App\Http\Controllers\TimesheetEntryController; use App\Http\Controllers\TimesheetEntryController;
use App\Http\Controllers\PipelineController; use App\Http\Controllers\PipelineController;
@@ -29,7 +28,8 @@
Route::get('/customers/{id}', [CustomerController::class, 'single']); Route::get('/customers/{id}', [CustomerController::class, 'single']);
Route::get('/customers', [CustomerController::class, 'index']); Route::get('/customers', [CustomerController::class, 'index']);
Route::get('/notes/{modelType}/{notableId}', [NoteController::class, 'index']); Route::get('/notes', [NoteController::class, 'index']);
Route::get('/notes/{modelType}/{notableId}', [NoteController::class, 'notesForModel']);
Route::delete('/notes/{id}', [NoteController::class, 'delete']); Route::delete('/notes/{id}', [NoteController::class, 'delete']);
Route::post('/notes', [NoteController::class, 'store']) Route::post('/notes', [NoteController::class, 'store'])
->name('customers.notes.store'); ->name('customers.notes.store');
@@ -39,6 +39,7 @@
return \App\Models\TodoType::all(); return \App\Models\TodoType::all();
}); });
Route::apiResource('/todos', TodoController::class); Route::apiResource('/todos', TodoController::class);
Route::get('/todos/{modelType}/{notableId}', [TodoController::class, 'todosForModel']);
Route::get('/products/', [ProductController::class, 'index']); Route::get('/products/', [ProductController::class, 'index']);
Route::get('/products/{id}', [ProductController::class, 'single']); Route::get('/products/{id}', [ProductController::class, 'single']);