Connect CalDAV todos to DB-Items, finish todo component and add it to pipeline items
This commit is contained in:
@@ -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
|
||||||
{
|
{
|
||||||
@@ -30,9 +31,53 @@ public function handle(CaldavService $service)
|
|||||||
|
|
||||||
$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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,38 @@ 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) {
|
||||||
|
$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()
|
public function show()
|
||||||
|
|||||||
@@ -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,24 @@ 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');
|
||||||
|
}])
|
||||||
|
->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,8 +39,11 @@ 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 +55,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 +71,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class Todo extends Model
|
|||||||
'last_modified',
|
'last_modified',
|
||||||
'parent',
|
'parent',
|
||||||
'object',
|
'object',
|
||||||
|
'todoable_id',
|
||||||
|
'todoable_type'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+92
-7
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<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'])
|
||||||
@@ -17,6 +18,12 @@ 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[]> = {};
|
||||||
|
|
||||||
@@ -83,8 +90,10 @@ const shouldDisplay = (todo: Todo) => {
|
|||||||
<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">
|
||||||
<!-- 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',
|
||||||
|
'text-warning! font-bold': groupKey === 'today'
|
||||||
|
}">
|
||||||
{{ groupKey === 'today' ? 'Heute' : groupKey === 'overdue' ? 'Verspätet' : groupKey }}
|
{{ groupKey === 'today' ? 'Heute' : groupKey === 'overdue' ? 'Verspätet' : groupKey }}
|
||||||
</h3>
|
</h3>
|
||||||
<hr>
|
<hr>
|
||||||
@@ -118,13 +127,15 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,35 @@ 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 ""
|
) return "border-l-4 border-destructive"
|
||||||
|
|
||||||
|
// Warning
|
||||||
|
if (item.dueDate && isSoon(item.dueDate) ||
|
||||||
|
item.nextTodoDueDate && isSoon(item.nextTodoDueDate)
|
||||||
|
) return "border-l-4 border-warning"
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +182,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 +198,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 +277,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 +339,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 +349,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
Vendored
+12
-2
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
-1
@@ -29,7 +29,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 +40,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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user