Two month of work
This commit is contained in:
@@ -26,3 +26,4 @@ yarn-error.log
|
|||||||
/.nova
|
/.nova
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
|
/.continue
|
||||||
|
|||||||
@@ -33,6 +33,31 @@ public function handle(CaldavService $service)
|
|||||||
Todo::upsert($todo->attributesToArray(), 'id');
|
Todo::upsert($todo->attributesToArray(), 'id');
|
||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
|
// Collect hrefs/URLs returned by the CalDAV server so we can remove local
|
||||||
|
// todos that belong to this calendar but were deleted on the server.
|
||||||
|
$hrefs = array_values(array_filter(array_map(function ($t) {
|
||||||
|
return $t->url ?? null;
|
||||||
|
}, $todos)));
|
||||||
|
|
||||||
|
// Remove local todos that have a URL in this calendar but weren't returned
|
||||||
|
// by the server (i.e. they were deleted remotely). Scope deletion by
|
||||||
|
// calendar prefix to avoid touching other calendars.
|
||||||
|
$calendarId = $service->getCalendarId();
|
||||||
|
$username = config('caldav.username') ?? '';
|
||||||
|
$calendarPrefix = 'calendars/' . $username . '/' . $calendarId;
|
||||||
|
|
||||||
|
if ($calendarId && $username) {
|
||||||
|
Todo::whereNotNull('url')
|
||||||
|
->where('url', 'like', '%' . $calendarPrefix . "%")
|
||||||
|
->whereNotIn('url', $hrefs ?: [''])
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old todos
|
||||||
|
Todo::where('status', 'COMPLETED')
|
||||||
|
->where('last_modified', '<', now()->subDays(30)) // TODO: get from settings
|
||||||
|
->where('due_date', '<', now()->subDays(30))
|
||||||
|
->delete();
|
||||||
|
|
||||||
Log::info("Synced " . count($todos) . " todos.");
|
Log::info("Synced " . count($todos) . " todos.");
|
||||||
$this->info("Synced " . count($todos) . " todos.");
|
$this->info("Synced " . count($todos) . " todos.");
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ class NoteController extends Controller
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* Display a listing of the resource.
|
||||||
|
* @param Request $request
|
||||||
|
* @param string $modelType The type of the model (e.g., 'Customer', 'Invoice')
|
||||||
|
* @param string $modelId The ID of the model
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
*/
|
*/
|
||||||
public function index(Request $request, $modelId)
|
public function index(Request $request, string $modelType, int $modelId)
|
||||||
{
|
{
|
||||||
$modelType = $request->route()->parameter('modelType');
|
$model = app("App\\Models\\" . $modelType)::findOrFail($modelId);
|
||||||
if (!$modelType) {
|
|
||||||
$modelType = $request->route()->getAction('modelType');
|
|
||||||
}
|
|
||||||
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
|
|
||||||
|
|
||||||
// Lade alle Notizen des Modells mit der Benutzerbeziehung
|
// Lade alle Notizen des Modells mit der Benutzerbeziehung
|
||||||
$notes = $model->notes()->with('user')->orderBy('created_at', 'desc')->get();
|
$notes = $model->notes()->with('user')->orderBy('created_at', 'desc')->get();
|
||||||
@@ -34,24 +34,30 @@ public function index(Request $request, $modelId)
|
|||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* Store a newly created resource in storage.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request, $modelId)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$modelType = $request->route()->parameter('modelType');
|
|
||||||
if (!$modelType) {
|
|
||||||
$modelType = $request->route()->getAction('modelType');
|
|
||||||
}
|
|
||||||
|
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
|
'userId' => 'required|integer|exists:users,id',
|
||||||
'text' => 'required|string',
|
'text' => 'required|string',
|
||||||
'userId' => 'required|integer'
|
'noteableId' => 'required|integer',
|
||||||
|
'noteableType' => 'required|string',
|
||||||
|
'createdAt' => 'sometimes|date|nullable'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Convert camelCase to snake_case
|
// Convert camelCase to snake_case
|
||||||
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
|
$model = app("App\\Models\\" . $snakeCaseData['noteable_type'])::findOrFail($snakeCaseData['noteable_id']);
|
||||||
$note = new Note($validatedData);
|
|
||||||
$note->user_id = $validatedData['userId'];
|
// Create a new Note instance
|
||||||
|
$note = new Note($snakeCaseData);
|
||||||
|
|
||||||
|
// Set the created_at field if it is provided
|
||||||
|
if (isset($snakeCaseData['created_at'])) {
|
||||||
|
$note->created_at = $snakeCaseData['created_at'];
|
||||||
|
}
|
||||||
|
|
||||||
$model->notes()->save($note);
|
$model->notes()->save($note);
|
||||||
|
|
||||||
return response()->json($this->single($note->id), 201);
|
return response()->json($this->single($note->id), 201);
|
||||||
@@ -64,22 +70,6 @@ public static function single($id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the specified resource.
|
|
||||||
*/
|
|
||||||
public function show(Note $note)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for editing the specified resource.
|
|
||||||
*/
|
|
||||||
public function edit(Note $note)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PipelineLane;
|
||||||
|
use App\Models\PipelineItem;
|
||||||
|
use App\Support\ApiDataTransformer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class PipelineController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$lanes = PipelineLane::with(['items' => function ($q) {
|
||||||
|
$q->withCount(['notes' => function ($query) {
|
||||||
|
$query->where('notable_type', 'App\Models\PipelineItem');
|
||||||
|
}])->orderBy('position');
|
||||||
|
}])->orderBy('position')->get();
|
||||||
|
|
||||||
|
return $lanes->map(function ($lane) {
|
||||||
|
return ApiDataTransformer::snakeToCamel($lane->toArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
return Inertia::render('Pipeline', [
|
||||||
|
'pipeline' => $this->index(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update positions in bulk. Expect array of objects with camelCase keys: id, stage, position
|
||||||
|
*/
|
||||||
|
public function updatePositions(Request $request)
|
||||||
|
{
|
||||||
|
$payload = $request->all();
|
||||||
|
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return response()->json(['message' => 'Invalid payload'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = array_map(function ($item) {
|
||||||
|
return ApiDataTransformer::camelToSnake((array)$item);
|
||||||
|
}, $payload);
|
||||||
|
|
||||||
|
$validator = Validator::make(['items' => $items], [
|
||||||
|
'items.*.id' => 'required|integer|exists:pipeline_items,id',
|
||||||
|
'items.*.pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id',
|
||||||
|
'items.*.title' => 'string',
|
||||||
|
'items.*.position' => 'required|integer|min:0',
|
||||||
|
'items.*.expected_revenue' => 'decimal',
|
||||||
|
'items.*.description' => 'string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(['errors' => $validator->errors()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
try {
|
||||||
|
foreach ($items as $it) {
|
||||||
|
PipelineItem::where('id', $it['id'])->update([
|
||||||
|
'pipeline_lane_id' => $it['pipeline_lane_id'],
|
||||||
|
'position' => $it['position'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
DB::commit();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('Failed to update pipeline positions: ' . $e->getMessage());
|
||||||
|
return response()->json(['message' => 'Failed to update positions'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\PipelineItem;
|
||||||
|
use App\Support\ApiDataTransformer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PipelineItemController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$pipelineItems = PipelineItem::withCount(['notes' => function ($query) {
|
||||||
|
$query->where('notable_type', 'App\Models\PipelineItem');
|
||||||
|
}])->orderBy('position')->get();
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($pipelineItems->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function single(int $id)
|
||||||
|
{
|
||||||
|
$pipelineItem = PipelineItem::withCount(['notes' => function ($query) {
|
||||||
|
$query->where('notable_type', 'App\Models\PipelineItem');
|
||||||
|
}])->orderBy('position')->findOrFail($id);
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id',
|
||||||
|
'title' => 'required|string',
|
||||||
|
'position' => 'required|integer|min:0',
|
||||||
|
'expected_revenue' => 'nullable|numeric',
|
||||||
|
'due_date' => 'nullable|date',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pipelineItem = PipelineItem::create($validatedData);
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, PipelineItem $pipelineItem)
|
||||||
|
{
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'pipeline_lane_id' => 'sometimes|integer|exists:pipeline_lanes,id',
|
||||||
|
'title' => 'sometimes|string',
|
||||||
|
'position' => 'sometimes|integer|min:0',
|
||||||
|
'expected_revenue' => 'nullable|numeric',
|
||||||
|
'due_date' => 'nullable|date',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pipelineItem->update($validatedData);
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function delete(int $id)
|
||||||
|
{
|
||||||
|
PipelineItem::findOrFail($id)->delete();
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use App\Models\Timesheet;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Support\ApiDataTransformer;
|
||||||
|
|
||||||
|
class TimesheetController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$timesheets = Timesheet::with(['entries' => function ($query) {
|
||||||
|
$query->selectRaw('
|
||||||
|
timesheet_id,
|
||||||
|
SUM(hours) as total_hours,
|
||||||
|
SUM(CASE WHEN billed = 1 THEN hours ELSE 0 END) as hours_billed,
|
||||||
|
MIN(date) as earliest_date,
|
||||||
|
MAX(date) as latest_date
|
||||||
|
')
|
||||||
|
->groupBy('timesheet_id');
|
||||||
|
}])->get();
|
||||||
|
|
||||||
|
// Sort timesheets by the updated_at timestamp of the latest entry
|
||||||
|
$timesheets = $timesheets->sortByDesc(function ($timesheet) {
|
||||||
|
$latestEntry = $timesheet->entries()->latest('updated_at')->first();
|
||||||
|
return $latestEntry ? $latestEntry->updated_at : $timesheet->updated_at;
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$snakeCaseData = $timesheets->map(function ($timesheet) {
|
||||||
|
$entryStats = $timesheet->entries->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $timesheet->id,
|
||||||
|
'title' => $timesheet->title,
|
||||||
|
'total_hours' => $entryStats->total_hours ?? 0,
|
||||||
|
'hours_billed' => $entryStats->hours_billed ?? 0,
|
||||||
|
'earliest_date' => $entryStats->earliest_date ?? null,
|
||||||
|
'latest_date' => $entryStats->latest_date ?? null,
|
||||||
|
'created_at' => $timesheet->created_at->toISOString(),
|
||||||
|
'updated_at' => $timesheet->updated_at->toISOString(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($snakeCaseData->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snakeCaseData = Timesheet::create($validated);
|
||||||
|
return ApiDataTransformer::snakeToCamel($snakeCaseData->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*/
|
||||||
|
public function show(Timesheet $timesheet)
|
||||||
|
{
|
||||||
|
return Inertia::render('Timesheets', [
|
||||||
|
'timesheetData' => $this->index()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Timesheet $timesheet)
|
||||||
|
{
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
|
$timesheet->update($snakeCaseData);
|
||||||
|
|
||||||
|
return $timesheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Timesheet $timesheet)
|
||||||
|
{
|
||||||
|
$timesheet->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\TimesheetEntry;
|
||||||
|
use App\Models\Timesheet;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Support\ApiDataTransformer;
|
||||||
|
|
||||||
|
class TimesheetEntryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$entries = TimesheetEntry::with(['timesheet', 'user'])->orderBy('date', 'asc')->get();
|
||||||
|
|
||||||
|
return $entries->map(function ($entry) {
|
||||||
|
return ApiDataTransformer::snakeToCamel($entry->toArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries for a specific timesheet
|
||||||
|
*/
|
||||||
|
public function getEntriesForTimesheet(Timesheet $timesheet)
|
||||||
|
{
|
||||||
|
$entries = $timesheet->entries()->with('user')->orderBy('date', 'asc')->get();
|
||||||
|
|
||||||
|
return $entries->map(function ($entry) {
|
||||||
|
return ApiDataTransformer::snakeToCamel($entry->toArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'timesheetId' => 'required|exists:timesheets,id',
|
||||||
|
'date' => 'required|date',
|
||||||
|
'userId' => 'required|exists:users,id',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'hours' => 'required|numeric',
|
||||||
|
'billed' => 'sometimes|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
|
$entry = TimesheetEntry::create($snakeCaseData);
|
||||||
|
$entry = TimesheetEntry::with(['timesheet', 'user'])->find($entry->id);
|
||||||
|
return ApiDataTransformer::snakeToCamel($entry->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*/
|
||||||
|
public function show(TimesheetEntry $timesheetEntry)
|
||||||
|
{
|
||||||
|
$entry = $timesheetEntry->load(['timesheet', 'user']);
|
||||||
|
return ApiDataTransformer::snakeToCamel($entry->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, TimesheetEntry $timesheetEntry)
|
||||||
|
{
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'timesheetId' => 'sometimes|exists:timesheets,id',
|
||||||
|
'date' => 'sometimes|date',
|
||||||
|
'userId' => 'sometimes|exists:users,id',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'hours' => 'sometimes|numeric',
|
||||||
|
'billed' => 'sometimes|boolean',
|
||||||
|
]);
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
|
$timesheetEntry->update($snakeCaseData);
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($timesheetEntry->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(TimesheetEntry $timesheetEntry)
|
||||||
|
{
|
||||||
|
$timesheetEntry->delete();
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleBilled(TimesheetEntry $timesheetEntry)
|
||||||
|
{
|
||||||
|
$timesheetEntry = $timesheetEntry->update(['billed' => !$timesheetEntry->billed]);
|
||||||
|
return ApiDataTransformer::snakeToCamel($timesheetEntry->toArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class TodoController extends Controller
|
|||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$todos = Todo::with('type')->get();
|
$todos = Todo::with('type')->orderBy('due_date')->get();
|
||||||
return ApiDataTransformer::snakeToCamel($todos->toArray());
|
return ApiDataTransformer::snakeToCamel($todos->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class Customer extends Model
|
class Customer extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ class LineItem extends Model
|
|||||||
'price',
|
'price',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_section' => 'boolean',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'price' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
public function invoice()
|
public function invoice()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Invoice::class);
|
return $this->belongsTo(Invoice::class);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public function user()
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polymorphic relationship to the notable model (e.g., Lead, Contact, PipelineItem, etc.)
|
||||||
|
// https://laravel.com/docs/12.x/eloquent-relationships#one-to-many-polymorphic-relations
|
||||||
public function notable()
|
public function notable()
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PipelineItem extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'pipeline_items';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'pipeline_lane_id',
|
||||||
|
'title',
|
||||||
|
'position',
|
||||||
|
'expected_revenue',
|
||||||
|
'due_date',
|
||||||
|
'description'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'next_action' => 'date',
|
||||||
|
'expected_revenue' => 'decimal:2',
|
||||||
|
'actions' => 'integer',
|
||||||
|
'position' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function pipelineLane()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PipelineLane::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notes
|
||||||
|
*/
|
||||||
|
public function notes()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Note::class, 'notable');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class PipelineLane extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'position',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'position' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pipeline items for the lane
|
||||||
|
*/
|
||||||
|
public function items()
|
||||||
|
{
|
||||||
|
return $this->hasMany(PipelineItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Timesheet extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['title'];
|
||||||
|
|
||||||
|
public function entries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TimesheetEntry::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TimesheetEntry extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'timesheet_id',
|
||||||
|
'date',
|
||||||
|
'user_id',
|
||||||
|
'description',
|
||||||
|
'hours',
|
||||||
|
'billed'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => 'datetime',
|
||||||
|
'billed' => 'boolean',
|
||||||
|
'hours' => 'float'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function timesheet(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Timesheet::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,12 @@ class User extends Authenticatable
|
|||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'two_factor_confirmed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('timesheets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('timesheets');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('timesheet_entries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('timesheet_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->dateTime('date');
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->float('hours');
|
||||||
|
$table->boolean('billed')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('timesheet_entries');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pipeline_lanes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->integer('position')->default(0)->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'position' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'last_modified' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pipeline_lanes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pipeline_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('pipeline_lane_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('title');
|
||||||
|
$table->integer('position')->default(0);
|
||||||
|
$table->decimal('expected_revenue', 10, 2)->nullable();
|
||||||
|
$table->timestamp('due_date')->nullable();
|
||||||
|
$table->string('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'due_date' => 'datetime',
|
||||||
|
'expected_revenue' => 'decimal:2',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'last_modified' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pipeline_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+1233
-442
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -32,6 +32,9 @@
|
|||||||
"@coders-tm/vue-number-format": "^3.35.4",
|
"@coders-tm/vue-number-format": "^3.35.4",
|
||||||
"@inertiajs/vue3": "^2.1.0",
|
"@inertiajs/vue3": "^2.1.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@tiptap/pm": "^3.19.0",
|
||||||
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
"@tiptap/vue-3": "^3.19.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"camelcase-keys": "^10.0.0",
|
"camelcase-keys": "^10.0.0",
|
||||||
@@ -40,9 +43,8 @@
|
|||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"lucide-vue-next": "^0.468.0",
|
"lucide-vue-next": "^0.468.0",
|
||||||
"moment": "^2.30.1",
|
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"reka-ui": "^2.7.0",
|
"reka-ui": "^2.8.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
|||||||
+79
-36
@@ -45,6 +45,9 @@ @theme inline {
|
|||||||
--color-warning: var(--warning);
|
--color-warning: var(--warning);
|
||||||
--color-warning-foreground: var(--warning-foreground);
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
|
||||||
|
--color-action: var(--action);
|
||||||
|
--color-action-foreground: var(--action-foreground);
|
||||||
|
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
@@ -57,6 +60,7 @@ @theme inline {
|
|||||||
|
|
||||||
--color-sidebar: var(--sidebar-background);
|
--color-sidebar: var(--sidebar-background);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-icon: var(--sidebar-icon);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
@@ -74,7 +78,7 @@ @theme inline {
|
|||||||
|
|
||||||
/* https://typescale.com/ /*
|
/* https://typescale.com/ /*
|
||||||
/* Major Third */
|
/* Major Third */
|
||||||
--text-4xl: 3.815rem;
|
/* --text-4xl: 3.815rem;
|
||||||
--text-3xl: 3.052rem;
|
--text-3xl: 3.052rem;
|
||||||
--text-2xl: 2.441rem;
|
--text-2xl: 2.441rem;
|
||||||
--text-xl: 1.953rem;
|
--text-xl: 1.953rem;
|
||||||
@@ -82,12 +86,22 @@ @theme inline {
|
|||||||
--text-md: 1.25rem;
|
--text-md: 1.25rem;
|
||||||
--text-base: 1rem;
|
--text-base: 1rem;
|
||||||
--text-sm: 0.8rem;
|
--text-sm: 0.8rem;
|
||||||
--text-xs: 0.64rem;
|
--text-xs: 0.64rem; */
|
||||||
|
|
||||||
|
/* Minor Third */
|
||||||
|
--text-4xl: 2.986rem;
|
||||||
|
--text-3xl: 2.488rem;
|
||||||
|
--text-2xl: 2.074rem;
|
||||||
|
--text-xl: 1.728rem;
|
||||||
|
--text-lg: 1.44rem;
|
||||||
|
--text-md: 1.2rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-sm: 0.833rem;
|
||||||
|
--text-xs: 0.694rem;
|
||||||
|
|
||||||
--shadow-arrow: 0 2px 1px rgb(0 0 0 / 0.1)
|
--shadow-arrow: 0 2px 1px rgb(0 0 0 / 0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||||
so we've added these compatibility styles to make sure everything still
|
so we've added these compatibility styles to make sure everything still
|
||||||
@@ -103,7 +117,7 @@ @layer base {
|
|||||||
::before,
|
::before,
|
||||||
::backdrop,
|
::backdrop,
|
||||||
::file-selector-button {
|
::file-selector-button {
|
||||||
border-color: var(--color-gray-200, currentColor);
|
border-color: var(--color-zinc-200, currentColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +126,31 @@ @layer utilities {
|
|||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
--font-sans: system-ui, sans-serif;
|
--font-sans: system-ui, sans-serif;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
background-color: var(--sidebar-background);
|
background-color: var(--main-background);
|
||||||
/* background: linear-gradient(45deg, var(--color-slate-100), var(--color-orange-100)); */
|
|
||||||
letter-spacing: 0.006em;
|
letter-spacing: 0.006em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1281px) { body, html { font-size: 16px; } }
|
||||||
|
/* @media (min-width: 1921px) { body, html { font-size: 18px; } } */
|
||||||
|
|
||||||
|
/* Fluid scaling */
|
||||||
|
/* @media screen and (min-width: 320px) {
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
font-size: calc(16px + 2 * ((100vw - 320px) / 1120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1440px) {
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -126,14 +160,14 @@ :root {
|
|||||||
--card-foreground: hsl(0 0% 3.9%);
|
--card-foreground: hsl(0 0% 3.9%);
|
||||||
--popover-foreground: hsl(0 0% 3.9%);
|
--popover-foreground: hsl(0 0% 3.9%);
|
||||||
--popover: var(--color-background);
|
--popover: var(--color-background);
|
||||||
--popover-foreground: var(--color-gray-900);
|
--popover-foreground: var(--color-zinc-900);
|
||||||
--primary: var(--color-orange-200);
|
--primary: var(--color-orange-500);
|
||||||
--primary-foreground: var(--color-orange-600);
|
--primary-foreground: var(--color-white);
|
||||||
--secondary: hsl(0 0% 92.1%);
|
--secondary: hsl(0 0% 92.1%);
|
||||||
--secondary-foreground: hsl(0 0% 9%);
|
--secondary-foreground: hsl(0 0% 9%);
|
||||||
--muted: var(--color-gray-50);
|
--muted: var(--color-zinc-50);
|
||||||
--muted-foreground: var(--color-gray-400);
|
--muted-foreground: var(--color-zinc-500);
|
||||||
--accent: var(--color-gray-50);
|
--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: hsl(0 0% 98%);
|
||||||
@@ -141,26 +175,29 @@ :root {
|
|||||||
--success-foreground: var(--color-foreground);
|
--success-foreground: var(--color-foreground);
|
||||||
--warning: var(--color-amber-300);
|
--warning: var(--color-amber-300);
|
||||||
--warning-foreground: var(--color-amber-900);
|
--warning-foreground: var(--color-amber-900);
|
||||||
--border: hsl(0 0% 92.8%);
|
--action: var(--color-blue-500);
|
||||||
|
--action-foreground: var(--color-white);
|
||||||
|
--border: var(--color-zinc-200);
|
||||||
--input: var(--color-zinc-100);
|
--input: var(--color-zinc-100);
|
||||||
--ring: hsl(0 0% 3.9%);
|
--ring: var(--color-zinc-200);
|
||||||
--chart-1: hsl(12 76% 61%);
|
--chart-1: hsl(12 76% 61%);
|
||||||
--chart-2: hsl(313, 58%, 39%);
|
--chart-2: hsl(313, 58%, 39%);
|
||||||
--chart-3: hsl(197 37% 24%);
|
--chart-3: hsl(197 37% 24%);
|
||||||
--chart-4: hsl(43 74% 66%);
|
--chart-4: hsl(43 74% 66%);
|
||||||
--chart-5: hsl(27 87% 67%);
|
--chart-5: hsl(27 87% 67%);
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--main-background: transparent;
|
--main-background: var(--color-zinc-50);
|
||||||
--sidebar-background: var(--color-slate-100);
|
--sidebar-background: oklch(95.5% 0.003 286.35);;
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
--sidebar-foreground: var(--foreground);
|
||||||
|
--sidebar-icon: var(--color-zinc-500);
|
||||||
--sidebar-primary: hsl(0 0% 10%);
|
--sidebar-primary: hsl(0 0% 10%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||||
--sidebar-accent: var(--color-white);
|
--sidebar-accent: var(--color-white);
|
||||||
--sidebar-accent-foreground: hsl(0 0% 30%);
|
--sidebar-accent-foreground: var(--color-foreground);
|
||||||
--sidebar-border: var(--color-gray-300);
|
--sidebar-border: oklch(84% 0.008 286.2);
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
--sidebar: hsl(0 0% 98%);
|
--sidebar: hsl(0 0% 98%);
|
||||||
--status-draft: var(--color-gray-100);
|
--status-draft: var(--color-zinc-100);
|
||||||
--status-sent: var(--color-sky-400);
|
--status-sent: var(--color-sky-400);
|
||||||
--status-paid: var(--color-lime-500);
|
--status-paid: var(--color-lime-500);
|
||||||
--status-due: var(--color-warning);
|
--status-due: var(--color-warning);
|
||||||
@@ -170,27 +207,28 @@ :root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: var(--color-neutral-800);
|
--background: oklch(24% 0 0);
|
||||||
--foreground: var(--color-neutral-300);
|
--foreground: var(--color-neutral-300);
|
||||||
--card: var(--color-neutral-800);
|
--card: var(--color-neutral-800);
|
||||||
--card-foreground: var(--color-neutral-300);
|
--card-foreground: var(--color-neutral-300);
|
||||||
/* --popover: hsl(0 0% 3.9%); */
|
|
||||||
--popover: var(--color-neutral-900);
|
--popover: var(--color-neutral-900);
|
||||||
--popover-foreground: var(--color-neutral-300);
|
--popover-foreground: var(--color-neutral-300);
|
||||||
--primary: var(--color-orange-300);
|
--primary: var(--color-amber-600);
|
||||||
--primary-foreground: var(--color-orange-400);
|
--primary-foreground: var(--color-amber-200);
|
||||||
--secondary: hsl(0 0% 14.9%);
|
--secondary: hsl(0 0% 14.9%);
|
||||||
--secondary-foreground: var(--color-neutral-300);
|
--secondary-foreground: var(--color-neutral-300);
|
||||||
--muted: var(--color-neutral-700);
|
--muted: var(--color-neutral-700);
|
||||||
--muted-foreground: var(--color-neutral-500);
|
--muted-foreground: var(--color-neutral-500);
|
||||||
--accent: oklch(25% 0 0);
|
--accent: var(--color-neutral-800);
|
||||||
--accent-foreground: var(--color-neutral-300);
|
--accent-foreground: var(--color-neutral-300);
|
||||||
--destructive: var(--color-red-600);
|
--destructive: var(--color-red-600);
|
||||||
--destructive-foreground: var(--color-red-200);
|
--destructive-foreground: var(--color-red-100);
|
||||||
--success: var(--color-lime-900);
|
--success: var(--color-lime-700);
|
||||||
--success-foreground: var(--color-lime-400);
|
--success-foreground: var(--color-lime-200);
|
||||||
--warning: var(--color-amber-900);
|
--warning: var(--color-amber-900);
|
||||||
--warning-foreground: var(--color-amber-400);
|
--warning-foreground: var(--color-amber-400);
|
||||||
|
--action: var(--color-blue-600);
|
||||||
|
--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);
|
||||||
--ring: var(--color-neutral-500);
|
--ring: var(--color-neutral-500);
|
||||||
@@ -199,17 +237,18 @@ .dark {
|
|||||||
--chart-3: hsl(30 80% 55%);
|
--chart-3: hsl(30 80% 55%);
|
||||||
--chart-4: hsl(280 65% 60%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
--main-background: transparent;
|
--main-background: oklch(29% 0 0);
|
||||||
--sidebar-background: var(--color-neutral-700);
|
--sidebar-background: var(--color-neutral-800);
|
||||||
--sidebar-foreground: hsl(0 0% 95.9%);
|
--sidebar-foreground: var(--foreground);
|
||||||
|
--sidebar-icon: var(--color-neutral-500);
|
||||||
--sidebar-primary: hsl(360, 100%, 100%);
|
--sidebar-primary: hsl(360, 100%, 100%);
|
||||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||||
--sidebar-accent: hsl(0 0% 15.9%);
|
--sidebar-accent: var(--color-neutral-700);
|
||||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||||
--sidebar-border: var(--color-neutral-600);
|
--sidebar-border: var(--color-neutral-700);
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
--sidebar: hsl(240 5.9% 10%);
|
||||||
--status-draft: var(--color-zinc-500);
|
--status-draft: var(--color-neutral-500);
|
||||||
--status-sent: var(--color-sky-700);
|
--status-sent: var(--color-sky-700);
|
||||||
--status-paid: var(--color-lime-700);
|
--status-paid: var(--color-lime-700);
|
||||||
--status-due: var(--color-warning);
|
--status-due: var(--color-warning);
|
||||||
@@ -226,6 +265,10 @@ @layer base {
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lucide {
|
||||||
|
stroke-width: 1.666;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@@ -251,6 +294,6 @@ @layer utilities {
|
|||||||
|
|
||||||
/* Backdrop */
|
/* Backdrop */
|
||||||
[data-slot=dialog-overlay] {
|
[data-slot=dialog-overlay] {
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(0.75px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/sidebar';
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/crm-sidebar';
|
||||||
import { dashboard, crm, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes';
|
import { useSidebar } from '@/components/ui/crm-sidebar/utils'
|
||||||
|
import { dashboard, pipeline, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
import { type NavGroup } from '@/types';
|
import { type NavGroup } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Kanban, Euro, Trophy, Calculator, BookUser, Timer, ShoppingBasket, Headset, Plus } from 'lucide-vue-next';
|
import { Kanban, Euro, Trophy, Calculator, Timer, ContactRound, Headset, Plus, Package } from 'lucide-vue-next';
|
||||||
import AppLogo from './AppLogo.vue';
|
import AppLogo from './AppLogo.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const { open } = useSidebar()
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const auth = computed(() => page.props.auth);
|
||||||
|
|
||||||
@@ -20,27 +23,23 @@ const mainNavGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Pipeline',
|
title: 'Pipeline',
|
||||||
href: crm(),
|
href: pipeline(),
|
||||||
icon: Kanban,
|
icon: Kanban,
|
||||||
color: 'text-pink-500',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Akquise',
|
title: 'Akquise',
|
||||||
href: leads(),
|
href: leads(),
|
||||||
icon: Headset,
|
icon: Headset,
|
||||||
color: 'text-blue-500',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kunden',
|
title: 'Kunden',
|
||||||
href: customers(),
|
href: customers(),
|
||||||
icon: BookUser,
|
icon: ContactRound,
|
||||||
color: 'text-lime-500',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Erfolge',
|
title: 'Erfolge',
|
||||||
href: achievements(),
|
href: achievements(),
|
||||||
icon: Trophy,
|
icon: Trophy,
|
||||||
color: 'text-amber-500',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -51,31 +50,26 @@ const mainNavGroups: NavGroup[] = [
|
|||||||
title: 'Angebote',
|
title: 'Angebote',
|
||||||
href: offers(),
|
href: offers(),
|
||||||
icon: Calculator,
|
icon: Calculator,
|
||||||
color: 'text-cyan-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Produkte',
|
|
||||||
href: products(),
|
|
||||||
icon: ShoppingBasket,
|
|
||||||
color: 'text-yellow-400',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rechnungen',
|
title: 'Rechnungen',
|
||||||
href: invoices(),
|
href: invoices(),
|
||||||
icon: Euro,
|
icon: Euro,
|
||||||
color: 'text-pink-700',
|
|
||||||
action: {
|
action: {
|
||||||
title: "Neue Rechnung",
|
title: "Neue Rechnung",
|
||||||
icon: Plus,
|
icon: Plus,
|
||||||
color: 'text-foreground',
|
|
||||||
href: newInvoice()
|
href: newInvoice()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Produkte',
|
||||||
|
href: products(),
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Zeiterfassung',
|
title: 'Zeiterfassung',
|
||||||
href: timesheets(),
|
href: timesheets(),
|
||||||
icon: Timer,
|
icon: Timer,
|
||||||
color: 'text-lime-600',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -84,7 +78,7 @@ const mainNavGroups: NavGroup[] = [
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar collapsible="icon" variant="sidebar">
|
<Sidebar collapsible="icon">
|
||||||
|
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<Link :href="dashboard()" prefetch class="flex row items-center mt-3">
|
<Link :href="dashboard()" prefetch class="flex row items-center mt-3">
|
||||||
@@ -93,11 +87,11 @@ const mainNavGroups: NavGroup[] = [
|
|||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger class="w-fit absolute top-9 right-[.666rem]">
|
<TooltipTrigger class="w-fit absolute top-9" :class="open ? 'right-[.666rem]' : 'right-[1.14333rem]'">
|
||||||
<SidebarTrigger class="hidden md:flex text-primary-foreground" />
|
<SidebarTrigger class="hidden md:flex" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<span>Seitenleiste schließen</span>
|
<span>Seitenleiste </span>
|
||||||
<KbdGroup class="ml-2">
|
<KbdGroup class="ml-2">
|
||||||
<Kbd class="visible-mac">⌘</Kbd>
|
<Kbd class="visible-mac">⌘</Kbd>
|
||||||
<Kbd class="visible-pc">Ctrl</Kbd>
|
<Kbd class="visible-pc">Ctrl</Kbd>
|
||||||
|
|||||||
@@ -248,8 +248,8 @@ const deleteNote = async (id: number) => {
|
|||||||
<!-- Save -->
|
<!-- Save -->
|
||||||
<Button v-if="customer && isDirty" class="grow md:grow-0" size="sm" @click="save"
|
<Button v-if="customer && isDirty" class="grow md:grow-0" size="sm" @click="save"
|
||||||
:disabled="isSaving">
|
:disabled="isSaving">
|
||||||
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
|
<Loader2 v-if="isSaving" class="animate-spin" />
|
||||||
<Check v-else stroke-width="1.5" />
|
<Check v-else />
|
||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ const deleteNote = async (id: number) => {
|
|||||||
<DropdownMenu v-if="customer && customer.id > 0">
|
<DropdownMenu v-if="customer && customer.id > 0">
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
|
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
|
||||||
<Ellipsis class="size-4" stroke-width="1.5" />
|
<Ellipsis class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ const deleteNote = async (id: number) => {
|
|||||||
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
|
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
|
||||||
@click="">
|
@click="">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Trash2 :strokeWidth="1.5" class="text-current" />
|
<Trash2 class="text-current" />
|
||||||
<span class="mr-2">Löschen</span>
|
<span class="mr-2">Löschen</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -449,7 +449,7 @@ const deleteNote = async (id: number) => {
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="font-bold">Nächste Schritte</h2>
|
<h2 class="font-bold">Nächste Schritte</h2>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<Plus stroke-width="1.5" />
|
<Plus />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -468,11 +468,11 @@ const deleteNote = async (id: number) => {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input v-model="todo.text"
|
<Input v-model="todo.text"
|
||||||
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
|
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
|
||||||
<PhoneCall v-if="todo.type === 'phoneCall'" stroke-width="1.5" :size="16"
|
<PhoneCall v-if="todo.type === 'phoneCall'" :size="16"
|
||||||
class="text-muted-foreground" />
|
class="text-muted-foreground" />
|
||||||
<Check v-else-if="todo.type === 'task'" stroke-width="1.5" :size="16"
|
<Check v-else-if="todo.type === 'task'" :size="16"
|
||||||
class="text-muted-foreground" />
|
class="text-muted-foreground" />
|
||||||
<Mail v-else-if="todo.type === 'mail'" stroke-width="1.5" :size="16"
|
<Mail v-else-if="todo.type === 'mail'" :size="16"
|
||||||
class="text-muted-foreground" />
|
class="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -490,7 +490,7 @@ const deleteNote = async (id: number) => {
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="font-bold">Notizen</h2>
|
<h2 class="font-bold">Notizen</h2>
|
||||||
<Button variant="ghost" @click="isTakingNote = true">
|
<Button variant="ghost" @click="isTakingNote = true">
|
||||||
<Plus stroke-width="1.5" />
|
<Plus />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -505,11 +505,11 @@ const deleteNote = async (id: number) => {
|
|||||||
<Kbd class="visible-mac">⌘</Kbd>
|
<Kbd class="visible-mac">⌘</Kbd>
|
||||||
<Kbd class="visible-pc">Ctrl</Kbd>
|
<Kbd class="visible-pc">Ctrl</Kbd>
|
||||||
<Kbd>
|
<Kbd>
|
||||||
<CornerDownLeft stroke-width="1.5" class="h-3 w-3" />
|
<CornerDownLeft class="h-3 w-3" />
|
||||||
</Kbd>
|
</Kbd>
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
<Button class="w-20" size="sm" variant="outline" @click="saveNote">
|
<Button class="w-20" size="sm" variant="outline" @click="saveNote">
|
||||||
<Send stroke-width="1.5" />
|
<Send />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -529,7 +529,7 @@ const deleteNote = async (id: number) => {
|
|||||||
<span>{{ toLocalDate(note.createdAt) }}</span>
|
<span>{{ toLocalDate(note.createdAt) }}</span>
|
||||||
<div class="grow-1"></div>
|
<div class="grow-1"></div>
|
||||||
<Button variant="ghost" size="sm" @click="deleteNote(note.id)">
|
<Button variant="ghost" size="sm" @click="deleteNote(note.id)">
|
||||||
<Trash2 stroke-width="1.5" />
|
<Trash2 />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm whitespace-pre-wrap">{{ note.text }}</div>
|
<div class="text-sm whitespace-pre-wrap">{{ note.text }}</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { X } from "lucide-vue-next"
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<X stroke-width="1.5" />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'
|
||||||
|
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { Button } from '../ui/crm-button'
|
||||||
|
import { Ellipsis, Menu } from "lucide-vue-next"
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: string
|
||||||
|
modelValue?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancel = (event: Event | null) => {
|
||||||
|
if (!event) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = true
|
||||||
|
|
||||||
|
emit('cancel')
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="isOpen">
|
||||||
|
<DialogContent
|
||||||
|
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] h-[calc(100dvh-2rem)] gap-0 p-0 outline-none"
|
||||||
|
@escapeKeyDown="cancel" @interactOutside="cancel">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<DialogTitle class="text-primary font-bold text-left">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<slot name="description"></slot>
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<TooltipProvider v-if="$slots.buttons || $slots.ellipsisMenuItems">
|
||||||
|
<slot name="buttons"></slot>
|
||||||
|
|
||||||
|
<!-- Ellipsis menu -->
|
||||||
|
<DropdownMenu v-if="$slots.ellipsisMenuItems">
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button variant="ghost" size="icon" class="">
|
||||||
|
<Ellipsis class="visible-mac" />
|
||||||
|
<Menu class="visible-pc" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<slot name="ellipsisMenuItems"></slot>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DialogClose as-child>
|
||||||
|
<DialogCloseButton />
|
||||||
|
</DialogClose>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-row">
|
||||||
|
<div class="p-4 md:p-6 lg:p-12 pt-0! grow">
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
<aside class="w-120 p-4 md:p-6 lg:p-12 pt-0! flex flex-col gap-4" v-if="$slots.sidebar">
|
||||||
|
<slot name="sidebar"></slot>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -9,8 +9,8 @@ defineProps<Props>();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mb-8 space-y-0.5">
|
<div class="mb-8 space-y-0.5">
|
||||||
<h2 class="text-xl font-semibold tracking-tight">{{ title }}</h2>
|
<h2 class="text-lg text-primary font-semibold">{{ title }}</h2>
|
||||||
<p v-if="description" class="text-sm text-muted-foreground">
|
<p v-if="description" class="text-muted-foreground">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/crm-sidebar';
|
||||||
import { User } from '@/types';
|
import { User } from '@/types';
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import UserInfo from '@/components/UserInfo.vue';
|
import UserInfo from '@/components/UserInfo.vue';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuAction } from '@/components/ui/sidebar';
|
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuAction } from '@/components/ui/crm-sidebar';
|
||||||
import { urlIsActive } from '@/lib/utils';
|
import { urlIsActive } from '@/lib/utils';
|
||||||
import { type NavGroup } from '@/types';
|
import { type NavGroup } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { LayoutGrid, Plus } from 'lucide-vue-next';
|
import { LayoutGrid } from 'lucide-vue-next';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import SidebarSeparator from './ui/sidebar/SidebarSeparator.vue';
|
import SidebarSeparator from './ui/crm-sidebar/SidebarSeparator.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
groups: NavGroup[];
|
groups: NavGroup[];
|
||||||
@@ -19,10 +19,10 @@ const page = usePage();
|
|||||||
<SidebarGroup class="px-2 py-0 mt-[1.8rem]">
|
<SidebarGroup class="px-2 py-0 mt-[1.8rem]">
|
||||||
|
|
||||||
<SidebarMenuItem :key="'dashboard'">
|
<SidebarMenuItem :key="'dashboard'">
|
||||||
<SidebarMenuButton as-child :is-active="urlIsActive(dashboard(), page.url)" :tooltip="'Dashboard'">
|
<SidebarMenuButton as-child :is-active="urlIsActive(dashboard(), page.url)" :tooltip="'Dashboard'" class="text-base">
|
||||||
<Link prefetch :href="dashboard()">
|
<Link prefetch :href="dashboard()">
|
||||||
<component :is="LayoutGrid" class="text-gray-500" stroke-width="1.5" />
|
<LayoutGrid class="text-muted-foreground" :class="urlIsActive(dashboard(), page.url) ? 'text-primary' : 'text-sidebar-icon'" />
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -30,23 +30,24 @@ const page = usePage();
|
|||||||
|
|
||||||
<SidebarGroup class="px-2 py-0" v-for="group in groups">
|
<SidebarGroup class="px-2 py-0" v-for="group in groups">
|
||||||
<SidebarSeparator
|
<SidebarSeparator
|
||||||
class="absolute mt-6 shrink transition-[opacity] opacity-0 group-data-[collapsible=icon]:opacity-100 w-4 bg-muted-foreground"
|
class="absolute mt-6 shrink transition-opacity opacity-0 group-data-[collapsible=icon]:opacity-100 w-4 bg-muted-foreground/50"
|
||||||
style="width: calc(var(--spacing) * 4);" />
|
style="width: calc(var(--spacing) * 4);" />
|
||||||
<SidebarGroupLabel class="text-bold uppercase text-gray-400 mt-2 group-data-[collapsible=icon]:mt-2">
|
<SidebarGroupLabel class="font-medium text-sm text-muted-foreground mt-2 group-data-[collapsible=icon]:mt-2">
|
||||||
{{ group.title }}
|
{{ group.title }}
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu class="font-medium text-base gap-2">
|
||||||
<SidebarMenuItem v-for="item in group.items" :key="item.title">
|
<SidebarMenuItem v-for="item in group.items" :key="item.title">
|
||||||
<SidebarMenuButton as-child :is-active="urlIsActive(item.href, page.url)" :tooltip="item.title">
|
<SidebarMenuButton as-child :is-active="urlIsActive(item.href, page.url)" :tooltip="item.title" class="text-base font-normal">
|
||||||
<Link prefetch :href="item.href">
|
<Link prefetch :href="item.href">
|
||||||
<component :is="item.icon" :class="item.color" stroke-width="1.5" />
|
<component :is="item.icon"
|
||||||
<span>{{ item.title }}</span>
|
:class="urlIsActive(item.href, page.url) ? 'text-primary' : 'text-sidebar-icon'" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
<SidebarMenuAction v-if="item.action">
|
<SidebarMenuAction v-if="item.action">
|
||||||
<Link prefetch :href="item.action.href">
|
<Link prefetch :href="item.action.href">
|
||||||
<component :is="item.action.icon" :class="item.action.color" stroke-width="1.5" size="0.833rem" />
|
<component :is="item.action.icon" class="text-sidebar-icon" size="0.833rem" />
|
||||||
<span class="sr-only">{{ item.action.title }}</span>
|
<span class="sr-only">{{ item.action.title }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuAction>
|
</SidebarMenuAction>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UserInfo from '@/components/UserInfo.vue';
|
import UserInfo from '@/components/UserInfo.vue';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
import { useSidebar } from '@/components/ui/sidebar';
|
import { useSidebar } from '@/components/ui/crm-sidebar';
|
||||||
import { usePage } from '@inertiajs/vue3';
|
import { usePage } from '@inertiajs/vue3';
|
||||||
import { ChevronsUpDown } from 'lucide-vue-next';
|
import { ChevronsUpDown } from 'lucide-vue-next';
|
||||||
import UserMenuContent from './UserMenuContent.vue';
|
import UserMenuContent from './UserMenuContent.vue';
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/crm-button'
|
||||||
|
import TextEditor from '@/components/TextEditor.vue';
|
||||||
|
import { Trash2, CornerDownLeft, Pencil, X } from "lucide-vue-next"
|
||||||
|
import { Note, NoteableType } from '@/types';
|
||||||
|
import { bgColorForString, toDatetimeLocal, toDuration, toShortISOString } from '@/lib/utils';
|
||||||
|
import { getInitials } from '@/composables/useInitials';
|
||||||
|
import { alertStore } from '@/stores/alertStore';
|
||||||
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Input } from './ui/crm-input';
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
import NotesService from '@/services/NotesService';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
notableId: number
|
||||||
|
notableType: NoteableType
|
||||||
|
title?: string
|
||||||
|
modelValue?: Note[]
|
||||||
|
}>()
|
||||||
|
defineEmits(['update:modelValue'])
|
||||||
|
const notes = ref(props.modelValue)
|
||||||
|
const isTakingNote = ref(false)
|
||||||
|
const noteEditor = useTemplateRef('note-editor')
|
||||||
|
const noteDate = ref<string>(toDatetimeLocal(new Date())) //
|
||||||
|
const alert = alertStore()
|
||||||
|
const page = usePage();
|
||||||
|
const auth = computed(() => page.props.auth);
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
notes.value = newValue
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleNoteEditor = () => {
|
||||||
|
isTakingNote.value = !isTakingNote.value
|
||||||
|
|
||||||
|
if (isTakingNote.value) {
|
||||||
|
noteDate.value = toDatetimeLocal(new Date())
|
||||||
|
noteEditor.value?.editor?.commands.clearContent()
|
||||||
|
noteEditor.value?.editor?.commands.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNote = async () => {
|
||||||
|
if (!noteEditor.value?.getContent().trim()) {
|
||||||
|
isTakingNote.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/notes`, {
|
||||||
|
userId: auth.value.user.id,
|
||||||
|
text: noteEditor.value?.getContent(),
|
||||||
|
noteableId: props.notableId,
|
||||||
|
noteableType: props.notableType,
|
||||||
|
createdAt: new Date(noteDate.value).toISOString() || new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to notes array and sort by creation date
|
||||||
|
notes.value?.unshift(response.data)
|
||||||
|
notes.value?.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
|
||||||
|
// Clear editor and hide the note editor
|
||||||
|
toggleNoteEditor()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Speichern der Notiz:", error);
|
||||||
|
toast.error("Fehler beim Speichern der Notiz", {
|
||||||
|
description: (error as AxiosError).message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteNote = async (id: number) => {
|
||||||
|
alert.show(
|
||||||
|
"Möchtest Du diese Notiz wirklich löschen?", null,
|
||||||
|
{
|
||||||
|
actionText: "Löschen",
|
||||||
|
actionVariant: "destructive",
|
||||||
|
onAction: async () => {
|
||||||
|
const deleted = await NotesService.deleteNote(id)
|
||||||
|
if (!deleted) return
|
||||||
|
|
||||||
|
// Remove from notes array
|
||||||
|
const index = notes.value?.findIndex(note => note.id === id)
|
||||||
|
if (index !== -1 && index !== undefined) {
|
||||||
|
notes.value?.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notes overflow-y-auto flex flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center gap-6 sticky top-0 bg-background z-1 mb-6">
|
||||||
|
<h2 class="font-bold text-md">{{ title || 'Notizen' }}</h2>
|
||||||
|
<Button variant="ghost" size="icon" @click="toggleNoteEditor">
|
||||||
|
<X v-if="isTakingNote" />
|
||||||
|
<Pencil v-else />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note editor -->
|
||||||
|
<div class="flex flex-col overflow-hidden transition-all min-h-40 bg-accent mb-8 rounded-lg p-4"
|
||||||
|
:class="{ 'h-0! min-h-0! mb-0 py-0': !isTakingNote }">
|
||||||
|
|
||||||
|
<Input type="datetime-local" ref="note-date" class="mb-4" :model-value="noteDate"
|
||||||
|
@update:model-value="value => noteDate = value as string" />
|
||||||
|
|
||||||
|
<TextEditor ref="note-editor" class="grow" />
|
||||||
|
|
||||||
|
<div class="flex gap-3 items-center justify-end">
|
||||||
|
<KbdGroup class="ml-2">
|
||||||
|
<Kbd class="visible-mac">⌘</Kbd>
|
||||||
|
<Kbd class="visible-pc">Ctrl</Kbd>
|
||||||
|
<Kbd>
|
||||||
|
<CornerDownLeft class="h-3 w-3" />
|
||||||
|
</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
<Button variant="action" size="sm" @click="saveNote">
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<article v-for="note in notes" class="group">
|
||||||
|
<div class="text-muted-foreground text-sm font-medium flex gap-3 items-center">
|
||||||
|
|
||||||
|
<Avatar class="size-7">
|
||||||
|
<AvatarImage :src="'storage/uploads/users/' + (note.user.avatar || '')" loading="lazy" />
|
||||||
|
<AvatarFallback :class="bgColorForString(getInitials(note.user.name))">
|
||||||
|
{{ getInitials(note.user.name) }}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<span class="text-sm">{{ toDuration(note.createdAt) }}</span>
|
||||||
|
|
||||||
|
<div class="grow"></div>
|
||||||
|
|
||||||
|
<div class="transition-opacity opacity-0 group-hover:opacity-100">
|
||||||
|
<Button variant="ghost" size="sm" @click="deleteNote(note.id)" class="text-muted-foreground">
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" @click="" class="text-muted-foreground">
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-html="note.text" class="note-content ml-3.5 mt-3 pl-6.5 border-l" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
.notes {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Editor, EditorContent, } from '@tiptap/vue-3'
|
||||||
|
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<{
|
||||||
|
modelValue?: string | null | undefined
|
||||||
|
}>()
|
||||||
|
const editor = ref<Editor>()
|
||||||
|
const getContent = (): string => editor.value?.getHTML() || ''
|
||||||
|
const isFocused = (): boolean => editor.value?.isFocused || false
|
||||||
|
|
||||||
|
defineExpose({ editor, getContent, isFocused });
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change:modelValue'])
|
||||||
|
|
||||||
|
// set editor content on prop change
|
||||||
|
watch(() => props.modelValue, (newValue, oldValue) => {
|
||||||
|
const isSame = editor.value?.getHTML() === newValue
|
||||||
|
if (isSame) return
|
||||||
|
editor.value?.commands.setContent(newValue as string)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
editor.value = new Editor({
|
||||||
|
extensions: [StarterKit],
|
||||||
|
content: props.modelValue,
|
||||||
|
onUpdate: () => {
|
||||||
|
if (!editor.value) return
|
||||||
|
emit('update:modelValue', editor.value.getHTML())
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
if (!editor.value) return
|
||||||
|
emit('change:modelValue', editor.value.getHTML())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Menu -->
|
||||||
|
<ButtonGroup class="editor-menu shadow border rounded-md overflow-clip z-1 bg-background">
|
||||||
|
<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" 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>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue"
|
||||||
|
import { Todo } from "@/types";
|
||||||
|
import { toLocalDate, toDuration, isToday, daysFromNow } from "@/lib/utils";
|
||||||
|
import { Mail, PhoneCall, ClipboardCheck } from "lucide-vue-next"
|
||||||
|
import { Badge } from '@/components/ui/crm-badge'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: Todo[] | null
|
||||||
|
showCompleted?: boolean
|
||||||
|
}>()
|
||||||
|
const todos = ref<Todo[]>([])
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const showCompleted = ref(props.showCompleted)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, value => {
|
||||||
|
todos.value = value as Todo[];
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedTodos = computed(() => {
|
||||||
|
const groups: Record<string, Todo[]> = {};
|
||||||
|
|
||||||
|
if (todos.value) {
|
||||||
|
for (let todo of todos.value) {
|
||||||
|
if (!todo.dueDate) continue
|
||||||
|
|
||||||
|
let dueDate = new Date(todo.dueDate)
|
||||||
|
|
||||||
|
// today
|
||||||
|
if (isToday(dueDate)) {
|
||||||
|
if (!groups['today']) groups['today'] = []
|
||||||
|
groups['today'].push(todo)
|
||||||
|
}
|
||||||
|
// overdue
|
||||||
|
else if (daysFromNow(dueDate) < 0) {
|
||||||
|
if (!groups['overdue']) groups['overdue'] = []
|
||||||
|
groups['overdue'].push(todo)
|
||||||
|
}
|
||||||
|
// by month
|
||||||
|
else {
|
||||||
|
let month = dueDate.toLocaleDateString('de-DE', { month: 'long' })
|
||||||
|
if (!groups[month]) groups[month] = []
|
||||||
|
groups[month].push(todo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const todoTitle = (title: string) => {
|
||||||
|
// If there's no title, return empty string
|
||||||
|
if (!title) return '';
|
||||||
|
|
||||||
|
// Check if the title contains brackets
|
||||||
|
const match = title.match(/^\[([^\]]+)\]\s*(.*)$/);
|
||||||
|
|
||||||
|
// If there's a match, return the part after the brackets
|
||||||
|
// Otherwise, return the full title
|
||||||
|
return match ? match[2] : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoBadge = (title: string) => {
|
||||||
|
// If there's no title, return empty string
|
||||||
|
if (!title) return '';
|
||||||
|
|
||||||
|
// Check if the title contains brackets
|
||||||
|
const match = title.match(/^\[([^\]]+)\]\s*(.*)$/);
|
||||||
|
|
||||||
|
// If there's a match, return the part inside the brackets
|
||||||
|
// Otherwise, return an empty string
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDisplay = (todo: Todo) => {
|
||||||
|
if (todo.status?.toLowerCase() !== 'completed') return true
|
||||||
|
|
||||||
|
return showCompleted.value; // && moment(todo.dueDate).isSameOrAfter(moment(new Date()), 'day')
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="todos" v-for="(todos, groupKey) in groupedTodos" :key="groupKey">
|
||||||
|
<!-- Group header -->
|
||||||
|
<h3 class="mt-4 mb-2 text-sm text-muted-foreground"
|
||||||
|
:class="{ 'text-destructive! font-bold': groupKey === 'overdue', 'text-foreground! font-bold': groupKey === 'today' }">
|
||||||
|
{{ groupKey === 'today' ? 'Heute' : groupKey === 'overdue' ? 'Verspätet' : groupKey }}
|
||||||
|
</h3>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li v-for="todo in todos" class="flex gap-3 items-baseline py-2.5 pr-1 transition-all"
|
||||||
|
:class="{ 'scale-y-0 h-0 py-0! my-0 origin-top': !shouldDisplay(todo) }">
|
||||||
|
|
||||||
|
<!-- Check mark -->
|
||||||
|
<div
|
||||||
|
class="relative top-0.75 shrink-0 h-4 aspect-square rounded-full border-muted-foreground has-[input:checked]:border-primary border flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0.5 rounded-full bg-transparent has-[input:checked]:bg-primary">
|
||||||
|
<input type="checkbox" class="absolute -inset-2 opacity-0"
|
||||||
|
:checked="todo.status?.toLowerCase() == 'completed'" :id="todo.id">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<div class="grow overflow-hidden truncate">
|
||||||
|
<!-- Priority -->
|
||||||
|
<span v-if="todo.priority < 5 && todo.priority > 0" class="mr-2 text-destructive">!!!</span>
|
||||||
|
<span v-if="todo.priority == 5" class="mr-2 text-warning-foreground">!!</span>
|
||||||
|
<span v-if="todo.priority > 5" class="mr-2 text-muted-foreground">!</span>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<label :for="todo.id" class="my-0 px-0 border-0 outline-0 shadow-none" :class="{
|
||||||
|
'line-through text-muted-foreground': todo.status?.toLowerCase() == 'completed'
|
||||||
|
}">
|
||||||
|
{{ todoTitle(todo.title) }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<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>
|
||||||
|
<span v-if="todo.dueDate"
|
||||||
|
:class="{ 'text-destructive font-bold': todo.status?.toLowerCase() != 'completed' && daysFromNow(todo.dueDate) < 0 }">
|
||||||
|
{{ toDuration(todo.dueDate) }}</span>
|
||||||
|
<!-- <Repeat v-if="todo.recurring" stroke-width="2" :size="14" /> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="relative top-0.75 text-muted-foreground shrink-0">
|
||||||
|
<PhoneCall v-if="todo.type?.name === 'phoneCall'" :size="18" />
|
||||||
|
<ClipboardCheck v-else-if="todo.type?.name === 'todo'" :size="18" />
|
||||||
|
<Mail v-else-if="todo.type?.name === 'mail'" :size="18" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -22,8 +22,8 @@ const showAvatar = computed(() => props.user.avatar && props.user.avatar !== '')
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar class="size-8 overflow-hidden rounded-lg">
|
<Avatar class="size-8">
|
||||||
<AvatarImage v-if="showAvatar" :src="'storage/uploads/users/' + user.avatar!" :alt="user.name" />
|
<AvatarImage v-if="showAvatar" :src="'storage/uploads/users/' + user.avatar" :alt="user.name" />
|
||||||
<AvatarFallback class="rounded-full bg-primary text-black dark:text-white">
|
<AvatarFallback class="rounded-full bg-primary text-black dark:text-white">
|
||||||
{{ getInitials(user.name) }}
|
{{ getInitials(user.name) }}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const handleLogout = () => {
|
|||||||
<DropdownMenuItem as-child>
|
<DropdownMenuItem as-child>
|
||||||
<Link :href="edit()" prefetch class="flex items-center justify-between">
|
<Link :href="edit()" prefetch class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Settings stroke-width="1.5" />
|
<Settings />
|
||||||
<span class="mr-4">Einstellungen</span>
|
<span class="mr-4">Einstellungen</span>
|
||||||
</div>
|
</div>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
@@ -35,7 +35,7 @@ const handleLogout = () => {
|
|||||||
<DropdownMenuItem as-child>
|
<DropdownMenuItem as-child>
|
||||||
<Link :href="proceduralDocumentation()" prefetch class="flex items-center justify-between">
|
<Link :href="proceduralDocumentation()" prefetch class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Book stroke-width="1.5" />
|
<Book />
|
||||||
<span class="mr-4">Verfahrensdokumentation</span>
|
<span class="mr-4">Verfahrensdokumentation</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -46,7 +46,7 @@ const handleLogout = () => {
|
|||||||
|
|
||||||
<DropdownMenuItem as-child>
|
<DropdownMenuItem as-child>
|
||||||
<Link class="block w-full" :href="logout()" @click="handleLogout" as="button" data-test="logout-button">
|
<Link class="block w-full" :href="logout()" @click="handleLogout" as="button" data-test="logout-button">
|
||||||
<LogOut stroke-width="1.5" class="mr-2 h-4 w-4" />
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
Log out
|
Log out
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { computed } from 'vue'
|
|||||||
import { Invoice } from '@/types'
|
import { Invoice } from '@/types'
|
||||||
import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils'
|
import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils'
|
||||||
import { StatusBadge, statusBadgeLabels, statusTextStyle, castToStatusVariant } from '@/components/ui/status-badge'
|
import { StatusBadge, statusBadgeLabels, statusTextStyle, castToStatusVariant } from '@/components/ui/status-badge'
|
||||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/crm-table'
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -104,25 +104,23 @@ const calcTaxes = (amount: number) => {
|
|||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Table class="relative document-table bg-none">
|
<Table class="relative document-table">
|
||||||
<TableHeader>
|
<TableHeader class="sticky top-0">
|
||||||
<TableRow class="hover:bg-transparent border-none">
|
<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>
|
||||||
<TableHead class="w-1/100 lg:w-1/20 lg:px-5 text-right hidden md:table-cell">Gestellt
|
<TableHead class="w-1/100 lg:w-1/20 lg:px-5 hidden md:table-cell">Gestellt</TableHead>
|
||||||
</TableHead>
|
<TableHead class="w-1/5 lg:hidden">Rechnung</TableHead>
|
||||||
<TableHead class="w-1/5 text-right lg:hidden">Rechnung</TableHead>
|
|
||||||
<TableHead class="w-1/5 hidden lg:table-cell">Kunde</TableHead>
|
<TableHead class="w-1/5 hidden lg:table-cell">Kunde</TableHead>
|
||||||
<TableHead colspan="2" class="w-1/3 hidden lg:table-cell">Betreff</TableHead>
|
<TableHead class="w-1/3 hidden lg:table-cell">Betreff</TableHead>
|
||||||
<TableHead class="w-1/12 text-right">Netto</TableHead>
|
<TableHead class="w-1/12 text-right">Netto</TableHead>
|
||||||
<TableHead class="w-1/12 text-right hidden lg:table-cell">Ust.</TableHead>
|
<TableHead class="w-1/12 text-right hidden lg:table-cell">Ust.</TableHead>
|
||||||
<TableHead class="w-1/12 text-right hidden lg:table-cell lg:pr-4">Brutto</TableHead>
|
<TableHead class="w-1/12 text-right hidden lg:table-cell lg:pr-4">Brutto</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody class="overflow-clip rounded-lg shadow">
|
<TableBody>
|
||||||
<TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)"
|
<TableRow v-for="invoice in invoices" :key="invoice.nr" @click="onItemClicked(invoice)"
|
||||||
class="select-none md:select-auto cursor-default bg-background"
|
|
||||||
:class="statusTextStyle(invoice.paymentStatus)">
|
:class="statusTextStyle(invoice.paymentStatus)">
|
||||||
<TableCell class="w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5 tabular-nums">{{ invoice.nr }}
|
<TableCell class="w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5 tabular-nums">{{ invoice.nr }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -131,7 +129,7 @@ const calcTaxes = (amount: number) => {
|
|||||||
statusBadgeLabels[invoice.paymentStatus] }}
|
statusBadgeLabels[invoice.paymentStatus] }}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="pr-5 hidden md:table-cell lg:px-5 text-right tabular-nums">{{
|
<TableCell class="pr-5 hidden md:table-cell lg:px-5 tabular-nums whitespace-nowrap">{{
|
||||||
toLocalDate(invoice.invoiceDate) }}
|
toLocalDate(invoice.invoiceDate) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="lg:hidden max-w-[220px] md:max-w-[320px] overflow-hidden text-ellipsis">
|
<TableCell class="lg:hidden max-w-[220px] md:max-w-[320px] overflow-hidden text-ellipsis">
|
||||||
@@ -141,7 +139,7 @@ const calcTaxes = (amount: number) => {
|
|||||||
<TableCell
|
<TableCell
|
||||||
class="hidden lg:table-cell max-w-[100px] md:max-w-[120px] lg:max-w-auto overflow-hidden text-ellipsis font-semibold">
|
class="hidden lg:table-cell max-w-[100px] md:max-w-[120px] lg:max-w-auto overflow-hidden text-ellipsis font-semibold">
|
||||||
{{ invoice.billingData?.companyName }}</TableCell>
|
{{ invoice.billingData?.companyName }}</TableCell>
|
||||||
<TableCell colspan="2"
|
<TableCell
|
||||||
class="hidden lg:table-cell max-w-[120px] md:max-w-[160px] lg:max-w-auto overflow-hidden text-ellipsis">
|
class="hidden lg:table-cell max-w-[120px] md:max-w-[160px] lg:max-w-auto overflow-hidden text-ellipsis">
|
||||||
{{
|
{{
|
||||||
invoice.title }}</TableCell>
|
invoice.title }}</TableCell>
|
||||||
@@ -154,10 +152,10 @@ const calcTaxes = (amount: number) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
||||||
<TableFooter class="border-none bg-transparent">
|
<TableFooter>
|
||||||
<!-- Summe -->
|
<!-- Summe -->
|
||||||
<TableRow class="border-none hover:bg-transparent">
|
<TableRow>
|
||||||
<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="py-4 text-right tabular-nums w-1/100 font-bold">Summe</TableCell>
|
<TableCell class="py-4 text-right tabular-nums w-1/100 font-bold">Summe</TableCell>
|
||||||
@@ -168,8 +166,8 @@ const calcTaxes = (amount: number) => {
|
|||||||
toCurrency(totalGross) }}</TableCell>
|
toCurrency(totalGross) }}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<!-- Bezahlt -->
|
<!-- Bezahlt -->
|
||||||
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)" class="border-none hover:bg-transparent">
|
<TableRow v-if="!(totalDue == 0 && 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=" w-1/100 text-right tabular-nums font-bold">Bezahlt</TableCell>
|
<TableCell class=" w-1/100 text-right tabular-nums font-bold">Bezahlt</TableCell>
|
||||||
@@ -178,11 +176,11 @@ const calcTaxes = (amount: number) => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
|
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
|
||||||
toCurrency(totalGrossPaid)
|
toCurrency(totalGrossPaid)
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
<TableRow v-if="totalDue > 0" class="border-none text-destructive hover:bg-transparent">
|
<TableRow v-if="totalDue > 0" class="text-destructive">
|
||||||
<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">Offen</TableCell>
|
<TableCell class="text-right tabular-nums w-1/100 font-bold">Offen</TableCell>
|
||||||
@@ -191,10 +189,10 @@ const calcTaxes = (amount: number) => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
|
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
|
||||||
toCurrency(totalGrossDue)
|
toCurrency(totalGrossDue)
|
||||||
}}</TableCell>
|
}}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
<TableRow v-if="totalNotIssued > 0" class="border-none text-muted-foreground hover:bg-transparent">
|
<TableRow v-if="totalNotIssued > 0">
|
||||||
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
|
<TableCell colspan="2" 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>
|
||||||
@@ -211,11 +209,6 @@ const calcTaxes = (amount: number) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.document-table {
|
|
||||||
font-size: 0.833rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-table th {}
|
|
||||||
|
|
||||||
.document-table td {
|
.document-table td {
|
||||||
padding-top: 1.125em !important;
|
padding-top: 1.125em !important;
|
||||||
|
|||||||
@@ -6,18 +6,16 @@
|
|||||||
<!-- TODO: Steuersatz in LineItem -->
|
<!-- TODO: Steuersatz in LineItem -->
|
||||||
<!-- TODO: Stunden und Tagessatz aus Settings -->
|
<!-- TODO: Stunden und Tagessatz aus Settings -->
|
||||||
<!-- TODO: Client-side validation -->
|
<!-- TODO: Client-side validation -->
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue"
|
import { ref, computed, watch, onMounted, onUpdated, toRaw, nextTick } from "vue"
|
||||||
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus, Unit } from "@/types"
|
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus, Unit } from "@/types"
|
||||||
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
|
import { newCustomer, newContact, newBillingData } from '@/types/index.d'
|
||||||
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
|
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded, hotkey } from '@/lib/utils'
|
||||||
import axios from 'axios'
|
import axios from "axios"
|
||||||
import { type DateValue, getLocalTimeZone, fromDate } from "@internationalized/date"
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableRow, } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableRow, } from '@/components/ui/crm-table'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'
|
||||||
import { Button } from '@/components/ui/crm-button'
|
import { Button } from '@/components/ui/crm-button'
|
||||||
import { Input } from '@/components/ui/crm-input';
|
import { Input } from '@/components/ui/crm-input';
|
||||||
@@ -28,35 +26,21 @@ import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion,
|
|||||||
import { alertStore } from "@/stores/alertStore"
|
import { alertStore } from "@/stores/alertStore"
|
||||||
import { GrowingTextarea } from '../ui/growing-textarea'
|
import { GrowingTextarea } from '../ui/growing-textarea'
|
||||||
import { toast } from "vue-sonner"
|
import { toast } from "vue-sonner"
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
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"
|
||||||
|
|
||||||
|
const DEBUG = ref(false)
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
invoiceData?: Invoice,
|
invoiceData: Invoice | undefined,
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
||||||
const invoice = ref<Invoice>()
|
|
||||||
const units = ref([] as Unit[])
|
|
||||||
const customers = ref([] as Customer[])
|
|
||||||
const paymentTermsData = ref([] as PaymentTerms[])
|
|
||||||
const isDirty = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
const itemsLoading = ref(false)
|
|
||||||
const importContact = ref(newContact() as Contact)
|
|
||||||
const importCustomer = ref(newCustomer() as Customer)
|
|
||||||
const alert = alertStore()
|
|
||||||
const reminderDialogOpen = ref(false)
|
|
||||||
const reminderLoading = ref(false)
|
|
||||||
const value = ref<DateValue>() // TODO: name properly
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -64,7 +48,21 @@ const isOpen = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = computed<string>(() => {
|
const invoice = ref<Invoice>()
|
||||||
|
const units = ref([] as Unit[])
|
||||||
|
const customers = ref([] as Customer[])
|
||||||
|
const paymentTerms = ref([] as PaymentTerms[])
|
||||||
|
const isDirty = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const itemsLoading = ref(false)
|
||||||
|
const importContact = ref(newContact() as Contact)
|
||||||
|
const importCustomer = ref(newCustomer() as Customer)
|
||||||
|
const alert = alertStore()
|
||||||
|
const reminderDialogOpen = ref(false)
|
||||||
|
const reminderLoading = ref(false)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const dataLoaded = ref(false)
|
||||||
|
const title = computed<string>(_ => {
|
||||||
if (invoice.value && invoice.value.id !== 0) {
|
if (invoice.value && invoice.value.id !== 0) {
|
||||||
return `Rechnung ${invoice.value.nr || ''}`
|
return `Rechnung ${invoice.value.nr || ''}`
|
||||||
} else {
|
} else {
|
||||||
@@ -72,83 +70,99 @@ const title = computed<string>(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load customers and payment terms
|
// Load additional data that wasn’t provided by parent invoices view
|
||||||
try {
|
try {
|
||||||
const promises = [];
|
const promises: Promise<any>[] = [];
|
||||||
promises.push(axios.get('/api/customers'))
|
|
||||||
promises.push(axios.get('/api/paymentterms'))
|
// Create an array of promises for each data request
|
||||||
promises.push(axios.get('/api/units'))
|
promises.push(axios.get("/api/customers"))
|
||||||
|
promises.push(axios.get("/api/paymentterms"))
|
||||||
|
promises.push(axios.get("/api/units"))
|
||||||
|
|
||||||
|
// Wait for all promises to resolve
|
||||||
const responses = await Promise.all(promises)
|
const responses = await Promise.all(promises)
|
||||||
let responseIndex = 0
|
|
||||||
customers.value = responses[responseIndex].data as Customer[]
|
// Process each response
|
||||||
paymentTermsData.value = responses[responseIndex + 1].data as PaymentTerms[]
|
customers.value = responses[0].data
|
||||||
units.value = responses[responseIndex + 2].data as Unit[]
|
paymentTerms.value = responses[0].data
|
||||||
|
units.value = responses[0].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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register hotkeys
|
||||||
|
hotkey('mod+i', importLineItems, null, () => isOpen.value)
|
||||||
|
hotkey('mod+e', exportPdf, null, () => isOpen.value)
|
||||||
|
hotkey('mod+p', preview, null, () => isOpen.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUpdated(() => {
|
// Initial data from parent view
|
||||||
if (isLoading.value) isLoading.value = false
|
watch(() => props.invoiceData, async () => {
|
||||||
// console.group('onUpdated')
|
// Reset state flag
|
||||||
// console.error(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
isDirty.value = false
|
||||||
// console.groupEnd()
|
dataLoaded.value = false
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
if (DEBUG.value) {
|
||||||
// on open
|
console.group('on parent data')
|
||||||
if (open) {
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
||||||
// console.group('on open')
|
}
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
||||||
|
|
||||||
// Reset state flags
|
// Get invoice data from props
|
||||||
isDirty.value = false;
|
invoice.value = props.invoiceData
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
// Get invoice data from props
|
// Load line items
|
||||||
// console.warn('trigger invoice watcher')
|
if (invoice.value && invoice.value.id !== 0) {
|
||||||
invoice.value = props.invoiceData
|
itemsLoading.value = true
|
||||||
|
|
||||||
// Load line items
|
try {
|
||||||
if (invoice.value && invoice.value.id !== 0) {
|
await axios.get('/api/lineitems/' + invoice.value.id).then(response => {
|
||||||
itemsLoading.value = true
|
if (invoice.value) invoice.value.items = response.data as LineItem[]
|
||||||
|
})
|
||||||
try {
|
} catch (error) {
|
||||||
itemsLoading.value = true
|
toast.error('Fehler beim Laden der Positionen', error || String(error))
|
||||||
axios.get('/api/lineitems/' + invoice.value.id).then(response => {
|
} finally {
|
||||||
if (invoice.value) invoice.value.items = response.data as LineItem[]
|
await nextTick()
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Fehler beim Laden der Positionen', error || String(error))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
itemsLoading.value = false
|
itemsLoading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
|
||||||
// console.groupEnd()
|
/* wait until next tick to reset loading state
|
||||||
|
*
|
||||||
|
* as changes made to customer and contacts here will probably
|
||||||
|
* change the invoice and would then trigger the invoice watcher agai
|
||||||
|
*/
|
||||||
|
await nextTick()
|
||||||
|
dataLoaded.value = true
|
||||||
|
if (DEBUG.value) {
|
||||||
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
||||||
|
console.groupEnd()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// watch changes on local invoice date
|
// watch changes on local invoice date
|
||||||
watch(invoice,
|
watch(invoice,
|
||||||
(newValue, oldValue) => {
|
async (newValue, oldValue) => {
|
||||||
if (newValue == oldValue) return
|
if (newValue == oldValue) return
|
||||||
|
|
||||||
// console.group('watch invoice')
|
if (DEBUG.value) {
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
console.group('watch invoice')
|
||||||
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading.value) {
|
if (dataLoaded.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
} else {
|
||||||
if (!newValue) {
|
if (!newValue) {
|
||||||
// console.groupEnd()
|
console.groupEnd()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default billing data from customer
|
// If no billing data is store in the invoice, generat ot from customer
|
||||||
if (!newValue.billingData) {
|
if (!newValue.billingData) {
|
||||||
// console.warn('trigger invoice watcher')
|
|
||||||
newValue.billingData = {
|
newValue.billingData = {
|
||||||
companyName: newValue.customer?.companyName || "",
|
companyName: newValue.customer?.companyName || "",
|
||||||
vatId: newValue.customer?.vatId || "",
|
vatId: newValue.customer?.vatId || "",
|
||||||
@@ -167,8 +181,6 @@ watch(invoice,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newValue.customerId && newValue.customerId !== 0) {
|
if (newValue.customerId && newValue.customerId !== 0) {
|
||||||
// if (importCustomer.value != newValue.customer)
|
|
||||||
// console.warn('trigger importCustomer watcher')
|
|
||||||
customers.value.find(customer => {
|
customers.value.find(customer => {
|
||||||
if (customer.id === newValue.customerId) {
|
if (customer.id === newValue.customerId) {
|
||||||
if (invoice.value) invoice.value.customer = customer as Customer
|
if (invoice.value) invoice.value.customer = customer as Customer
|
||||||
@@ -177,8 +189,6 @@ watch(invoice,
|
|||||||
customer.contacts.find(contact => {
|
customer.contacts.find(contact => {
|
||||||
if (contact.firstName === newValue?.billingData?.contactFirstName &&
|
if (contact.firstName === newValue?.billingData?.contactFirstName &&
|
||||||
contact.lastName === newValue?.billingData?.contactLastName) {
|
contact.lastName === newValue?.billingData?.contactLastName) {
|
||||||
// if (importContact.value != contact)
|
|
||||||
// console.warn('trigger importContact watcher')
|
|
||||||
importContact.value = contact
|
importContact.value = contact
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -186,15 +196,12 @@ watch(invoice,
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
value.value = fromDate(new Date(newValue.invoiceDate), getLocalTimeZone())
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
isDirty.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
if (DEBUG.value) {
|
||||||
// console.groupEnd()
|
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
@@ -204,13 +211,14 @@ watch(importCustomer,
|
|||||||
if (newValue == oldValue) return
|
if (newValue == oldValue) return
|
||||||
|
|
||||||
if (!invoice.value) return
|
if (!invoice.value) return
|
||||||
|
if (DEBUG.value) {
|
||||||
// console.group('watch importCustomer')
|
console.group('watch importCustomer')
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
console.log(`isDirty: ${isDirty.value}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Don't overwrite these values during loading
|
// Don't overwrite these values during loading
|
||||||
// they can intentionally be different from customer data
|
// they can intentionally be different from customer data
|
||||||
if (!isLoading.value) {
|
if (dataLoaded.value) {
|
||||||
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
|
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
|
||||||
|
|
||||||
// console.warn('trigger invoice watcher')
|
// console.warn('trigger invoice watcher')
|
||||||
@@ -232,8 +240,10 @@ watch(importCustomer,
|
|||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
if (DEBUG.value) {
|
||||||
// console.groupEnd()
|
console.log(`isDirty: ${isDirty.value}`)
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
@@ -243,10 +253,12 @@ watch(importContact,
|
|||||||
if (newValue == oldValue) return
|
if (newValue == oldValue) return
|
||||||
if (!invoice.value) return
|
if (!invoice.value) return
|
||||||
|
|
||||||
// console.group('watch importContact')
|
if (DEBUG.value) {
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
console.group('watch importContact')
|
||||||
|
console.log(`isDirty: ${isDirty.value}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLoading.value) {
|
if (dataLoaded.value) {
|
||||||
if (newValue.id !== 0) {
|
if (newValue.id !== 0) {
|
||||||
invoice.value.billingData!.contactFirstName = newValue.firstName
|
invoice.value.billingData!.contactFirstName = newValue.firstName
|
||||||
invoice.value.billingData!.contactLastName = newValue.lastName
|
invoice.value.billingData!.contactLastName = newValue.lastName
|
||||||
@@ -258,8 +270,10 @@ watch(importContact,
|
|||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
|
if (DEBUG.value) {
|
||||||
// console.groupEnd()
|
console.log(`isDirty: ${isDirty.value}`)
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
@@ -278,7 +292,7 @@ const save = async () => {
|
|||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prepare the invoice data for API request
|
// Prepare the invoice data API request
|
||||||
const invoiceToSave = {
|
const invoiceToSave = {
|
||||||
nr: invoice.value.nr,
|
nr: invoice.value.nr,
|
||||||
invoiceDate: invoice.value.invoiceDate,
|
invoiceDate: invoice.value.invoiceDate,
|
||||||
@@ -322,11 +336,10 @@ const save = async () => {
|
|||||||
invoice.value = response.data;
|
invoice.value = response.data;
|
||||||
} else {
|
} else {
|
||||||
// Update existing invoice
|
// Update existing invoice
|
||||||
const response = await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
|
await axios.put(`/api/invoices/${invoice.value.id}`, invoiceToSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('save', invoice.value)
|
emit('save', invoice.value)
|
||||||
// isOpen.value = false
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Rechnung konnte nicht gespeichert werden", {
|
toast.error("Rechnung konnte nicht gespeichert werden", {
|
||||||
description: (error as Error).message,
|
description: (error as Error).message,
|
||||||
@@ -334,7 +347,7 @@ const save = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
// remove spinner from save button
|
// remove spinner from save button
|
||||||
isSaving.value = false
|
isSaving.value = false
|
||||||
setTimeout(() => { isDirty.value = false }, 1000)
|
isDirty.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -369,12 +382,12 @@ const preview = function () {
|
|||||||
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
|
window?.open('/invoice/' + invoice.value.id, '_blank')?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadPdf = function () {
|
const exportPdf = function () {
|
||||||
if (!invoice.value) return;
|
if (!invoice.value) return;
|
||||||
window?.open('/invoice/' + invoice.value.id + '/pdf');
|
window?.open('/invoice/' + invoice.value.id + '/pdf');
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadXml = function () {
|
const exportXml = function () {
|
||||||
if (!invoice.value) return;
|
if (!invoice.value) return;
|
||||||
window?.open('/invoice/' + invoice.value.id + '/xml');
|
window?.open('/invoice/' + invoice.value.id + '/xml');
|
||||||
}
|
}
|
||||||
@@ -444,11 +457,11 @@ const sendReminder = async function (to: string | undefined, cc: string | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateLineItems = (newItems: LineItem[]) => {
|
const updateLineItems = (newItems: LineItem[]) => {
|
||||||
if (isLoading.value) return;
|
if (!dataLoaded.value) return;
|
||||||
if (!invoice.value) return;
|
if (!invoice.value) return;
|
||||||
|
|
||||||
// console.group('updateLineItems');
|
console.group('updateLineItems');
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
|
console.log(`isDirty: ${isDirty.value}`);
|
||||||
|
|
||||||
// Konvertiere die neuen Items in normale Objekte
|
// Konvertiere die neuen Items in normale Objekte
|
||||||
const rawItems = toRaw(newItems) || [];
|
const rawItems = toRaw(newItems) || [];
|
||||||
@@ -456,13 +469,13 @@ const updateLineItems = (newItems: LineItem[]) => {
|
|||||||
// Sortiere die Items nach position
|
// Sortiere die Items nach position
|
||||||
const sortedItems = [...rawItems].sort((a, b) => a.position - b.position);
|
const sortedItems = [...rawItems].sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
// Erstellen Sie eine tiefe Kopie der neuen Items
|
// Create a deep copy of the new items
|
||||||
const updatedItems = JSON.parse(JSON.stringify(sortedItems));
|
const updatedItems = JSON.parse(JSON.stringify(sortedItems));
|
||||||
|
|
||||||
// Aktualisieren Sie die Items in der Rechnung
|
// Update the invoice items
|
||||||
invoice.value.items = updatedItems;
|
invoice.value.items = updatedItems;
|
||||||
|
|
||||||
// Berechnen Sie den neuen Gesamtbetrag
|
// Calculate the new total amount
|
||||||
let total = 0;
|
let total = 0;
|
||||||
updatedItems.forEach(item => {
|
updatedItems.forEach(item => {
|
||||||
total += item.quantity * item.price;
|
total += item.quantity * item.price;
|
||||||
@@ -472,11 +485,13 @@ const updateLineItems = (newItems: LineItem[]) => {
|
|||||||
// Erzwingen Sie eine Aktualisierung der Benutzeroberfläche
|
// Erzwingen Sie eine Aktualisierung der Benutzeroberfläche
|
||||||
invoice.value = { ...invoice.value };
|
invoice.value = { ...invoice.value };
|
||||||
|
|
||||||
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
|
console.log(`isDirty: ${isDirty.value}`);
|
||||||
// console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
const importLineItems = () => {
|
const importLineItems = () => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
if (!fileInput.value) {
|
if (!fileInput.value) {
|
||||||
fileInput.value = document.createElement('input');
|
fileInput.value = document.createElement('input');
|
||||||
fileInput.value.type = 'file';
|
fileInput.value.type = 'file';
|
||||||
@@ -495,18 +510,14 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
formData.append('csv', file);
|
formData.append('csv', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!invoice.value || !invoice.value.id) {
|
const response = await axios.post(`/api/lineitems/import`, formData, {
|
||||||
throw new Error('Rechnung muss gespeichert werden, bevor Positionen importiert werden können');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.post(`/api/lineitems/import/${invoice.value.id}`, formData, {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (invoice.value) {
|
if (invoice.value) {
|
||||||
invoice.value.items = response.data;
|
invoice.value.items = invoice.value.items.concat(response.data as LineItem[]);
|
||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +550,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Input v-if="invoice" v-model="invoice.title"
|
<Input v-if="invoice" v-model="invoice.title" :id="'invoice-title'"
|
||||||
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"
|
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"
|
||||||
type="text" placeholder="Titel" />
|
type="text" placeholder="Titel" />
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -550,8 +561,8 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<!-- Save -->
|
<!-- Save -->
|
||||||
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
|
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
|
||||||
:disabled="isSaving">
|
:disabled="isSaving">
|
||||||
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
|
<Loader2 v-if="isSaving" class="animate-spin" />
|
||||||
<Check v-else stroke-width="1.5" />
|
<Check v-else />
|
||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -573,7 +584,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
|
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button size="sm" variant="success" @click="updateStatus('paid')">
|
<Button size="sm" variant="success" @click="updateStatus('paid')">
|
||||||
<FileCheck stroke-width="1.5" /> Bezahlt
|
<FileCheck /> Bezahlt
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -587,7 +598,6 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<Button size="sm" variant="destructive" @click="openReminderDialog"
|
<Button size="sm" variant="destructive" @click="openReminderDialog"
|
||||||
:disabled="reminderLoading" class="gap-0">
|
:disabled="reminderLoading" class="gap-0">
|
||||||
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
|
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
|
||||||
stroke-width="1.5"
|
|
||||||
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
|
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
|
||||||
Erinnern
|
Erinnern
|
||||||
</Button>
|
</Button>
|
||||||
@@ -599,10 +609,10 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
|
|
||||||
|
|
||||||
<!-- Ellipsis menu -->
|
<!-- Ellipsis menu -->
|
||||||
<DropdownMenu v-if="invoice && invoice.id > 0">
|
<DropdownMenu v-if="invoice">
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
|
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
|
||||||
<Ellipsis class="size-4" stroke-width="1.5" />
|
<Ellipsis class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
@@ -612,7 +622,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<!-- Import Items -->
|
<!-- Import Items -->
|
||||||
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
|
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Import :strokeWidth="1.5" class="text-muted-foreground" />
|
<Import class="text-muted-foreground" />
|
||||||
<div class="mr-4 flex flex-col">
|
<div class="mr-4 flex flex-col">
|
||||||
<span>Posten importieren</span>
|
<span>Posten importieren</span>
|
||||||
<span class="text-xs text-muted-foreground">(CSV)</span>
|
<span class="text-xs text-muted-foreground">(CSV)</span>
|
||||||
@@ -628,10 +638,9 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<!-- Preview -->
|
<!-- Preview -->
|
||||||
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
|
<DropdownMenuItem class="flex items-center justify-between" @click="preview">
|
||||||
class="flex items-center justify-between" @click="preview">
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Eye :strokeWidth="1.5" class="text-muted-foreground" />
|
<Eye class="text-muted-foreground" />
|
||||||
<span class="mr-4">Vorschau</span>
|
<span class="mr-4">Vorschau</span>
|
||||||
</div>
|
</div>
|
||||||
<KbdGroup>
|
<KbdGroup>
|
||||||
@@ -643,9 +652,9 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
|
|
||||||
<!-- PDF -->
|
<!-- PDF -->
|
||||||
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
||||||
class="flex justify-between" @click="downloadPdf">
|
class="flex justify-between" @click="exportPdf">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<FileText stroke-width="1.5" class="text-muted-foreground" />
|
<FileText class="text-muted-foreground" />
|
||||||
<div class="mr-4 flex flex-col">
|
<div class="mr-4 flex flex-col">
|
||||||
<span>PDF exportieren</span>
|
<span>PDF exportieren</span>
|
||||||
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
|
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
|
||||||
@@ -660,9 +669,9 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
|
|
||||||
<!-- XML -->
|
<!-- XML -->
|
||||||
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
||||||
class="flex justify-between" @click="downloadXml">
|
class="flex justify-between" @click="exportXml">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<CodeXml stroke-width="1.5" class="text-muted-foreground" />
|
<CodeXml class="text-muted-foreground" />
|
||||||
<div class="mr-4 flex flex-col">
|
<div class="mr-4 flex flex-col">
|
||||||
<span>XML exportieren</span>
|
<span>XML exportieren</span>
|
||||||
<span class="text-xs text-muted-foreground">(XRechnung)</span>
|
<span class="text-xs text-muted-foreground">(XRechnung)</span>
|
||||||
@@ -676,7 +685,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
|
||||||
class="flex justify-between" @click="" disabled>
|
class="flex justify-between" @click="" disabled>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Logs stroke-width="1.5" class="text-muted-foreground" />
|
<Logs class="text-muted-foreground" />
|
||||||
<span>Audit</span>
|
<span>Audit</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -688,8 +697,8 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
|
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
|
||||||
class="flex justify-between" @click="updateStatus('cancelled')">
|
class="flex justify-between" @click="updateStatus('cancelled')">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> -->
|
<!-- <FileX class="text-muted-foreground"/> -->
|
||||||
<Ban :strokeWidth="1.5" class="text-current" />
|
<Ban class="text-current" />
|
||||||
<span class="mr-2">Stornieren</span>
|
<span class="mr-2">Stornieren</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -699,7 +708,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
|
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
|
||||||
@click="deleteInvoice">
|
@click="deleteInvoice">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Trash2 :strokeWidth="1.5" class="text-current" />
|
<Trash2 class="text-current" />
|
||||||
<span class="mr-2">Löschen</span>
|
<span class="mr-2">Löschen</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -832,7 +841,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<MessageCircleQuestion strokeWidth="1.5"
|
<MessageCircleQuestion
|
||||||
class="h-[16px] w-[16px] ml-[1px] mb-2" />
|
class="h-[16px] w-[16px] ml-[1px] mb-2" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -851,7 +860,7 @@ const handleFileUpload = async (event: Event) => {
|
|||||||
<SelectValue placeholder="Zahlungsziel" />
|
<SelectValue placeholder="Zahlungsziel" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="term in paymentTermsData" :value="term">
|
<SelectItem v-for="term in paymentTerms" :value="term">
|
||||||
<span v-if="term.isFixed">{{ term.description }}</span>
|
<span v-if="term.isFixed">{{ term.description }}</span>
|
||||||
<span v-else>{{ term.days }} Tage</span>
|
<span v-else>{{ term.days }} Tage</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ import draggable from 'vuedraggable';
|
|||||||
import { cn, toCurrency } from '@/lib/utils';
|
import { cn, toCurrency } from '@/lib/utils';
|
||||||
import { LineItem, Product, Unit } from '@/types';
|
import { LineItem, Product, Unit } from '@/types';
|
||||||
import { newLineItem, newUnit } from '@/types/index.d'
|
import { newLineItem, newUnit } from '@/types/index.d'
|
||||||
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
|
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/crm-table';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
|
||||||
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
|
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
|
||||||
import { Input } from '@/components/ui/crm-input';
|
import { Input } from '@/components/ui/crm-input';
|
||||||
import { Loader2, GripVertical, Trash2, Plus, TextSelect, CheckIcon, ChevronsUpDownIcon } from 'lucide-vue-next';
|
import { Loader2, GripVertical, Trash2, Plus, TextSelect, CheckIcon, ChevronsUpDownIcon } from 'lucide-vue-next';
|
||||||
import { Button } from '@/components/ui/crm-button';
|
import { Button } from '@/components/ui/crm-button';
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'
|
||||||
import NumberInput from '@/components/ui/number-input/NumberInput.vue';
|
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
|
||||||
import { GrowingTextarea } from '@/components/ui/growing-textarea';
|
import { GrowingTextarea } from '@/components/ui/growing-textarea';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'
|
||||||
import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'
|
||||||
import axios, { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
|
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
|
||||||
|
|
||||||
|
|
||||||
const DEBUG = ref(true)
|
const DEBUG = ref(true)
|
||||||
@@ -194,7 +195,7 @@ const recalculatePositions = () => {
|
|||||||
|
|
||||||
<TableRow v-if="element.isSection">
|
<TableRow v-if="element.isSection">
|
||||||
<TableCell class="handle px-1 cursor-move w-6">
|
<TableCell class="handle px-1 cursor-move w-6">
|
||||||
<GripVertical :size="18" stroke-width="1.5" class="text-muted-foreground" />
|
<GripVertical :size="18" class="text-muted-foreground" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
@@ -210,7 +211,7 @@ const recalculatePositions = () => {
|
|||||||
<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)"
|
||||||
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
|
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
|
||||||
<Trash2 :size="18" stroke-width="1.5" />
|
<Trash2 :size="18" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -218,7 +219,7 @@ const recalculatePositions = () => {
|
|||||||
|
|
||||||
<TableRow v-else>
|
<TableRow v-else>
|
||||||
<TableCell class="handle px-1 cursor-move w-6">
|
<TableCell class="handle px-1 cursor-move w-6">
|
||||||
<GripVertical :size="18" stroke-width="1.5" class="text-muted-foreground" />
|
<GripVertical :size="18" class="text-muted-foreground" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<!-- Pos. -->
|
<!-- Pos. -->
|
||||||
@@ -272,19 +273,19 @@ const recalculatePositions = () => {
|
|||||||
|
|
||||||
<!-- Total -->
|
<!-- Total -->
|
||||||
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
|
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
|
||||||
}}
|
}}
|
||||||
</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)"
|
||||||
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
|
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
|
||||||
<Trash2 stroke-width="1.5" />
|
<Trash2 />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- <Button variant="ghost" size="sm" @click=""
|
<!-- <Button variant="ghost" size="sm" @click=""
|
||||||
class="has-[>svg]:px-1 text-muted-foreground">
|
class="has-[>svg]:px-1 text-muted-foreground">
|
||||||
<BetweenHorizonalEnd stroke-width="1.5"/>
|
<BetweenHorizonalEnd />
|
||||||
</Button> -->
|
</Button> -->
|
||||||
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -299,18 +300,18 @@ const recalculatePositions = () => {
|
|||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
|
|
||||||
<Button class="mt-4" variant="ghost" @click="newItem(true)">
|
<Button class="mt-4" variant="ghost" @click="newItem(true)">
|
||||||
<Plus stroke-width="1.5" /> Neuer Abschnitt
|
<Plus /> Neuer Abschnitt
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button class="mt-4" variant="ghost" @click="newItem(false)">
|
<Button class="mt-4" variant="ghost" @click="newItem(false)">
|
||||||
<Plus stroke-width="1.5" /> Neue Zeile
|
<Plus /> Neue Zeile
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Popover v-model:open="productsComboboxOpen">
|
<Popover v-model:open="productsComboboxOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" role="combobox" :aria-expanded="productsComboboxOpen" class="mt-4"
|
<Button variant="ghost" role="combobox" :aria-expanded="productsComboboxOpen" class="mt-4"
|
||||||
:disabled="!products || products.length === 0">
|
:disabled="!products || products.length === 0">
|
||||||
<Plus stroke-width="1.5" />
|
<Plus />
|
||||||
Produkt hinzufügen
|
Produkt hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -326,7 +327,7 @@ const recalculatePositions = () => {
|
|||||||
}" class="hover:bg-accent">
|
}" class="hover:bg-accent">
|
||||||
|
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail -->
|
||||||
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-lg">
|
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-sm shrink-0">
|
||||||
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
|
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
|
||||||
class="size-full object-cover dark:brightness-75" loading="lazy" />
|
class="size-full object-cover dark:brightness-75" loading="lazy" />
|
||||||
<PlaceholderPattern v-else />
|
<PlaceholderPattern v-else />
|
||||||
@@ -348,59 +349,63 @@ const recalculatePositions = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Loader2 v-if="props.isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground"
|
<Loader2 v-if="props.isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground"
|
||||||
stroke-width="1.5" />
|
/>
|
||||||
|
|
||||||
<Empty v-else-if="items.length < 1" class="py-8!">
|
<Empty v-else-if="items.length < 1" class="py-8!">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
<TextSelect class="text-muted-foreground" stroke-width="1.5" />
|
<TextSelect class="text-muted-foreground" />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
|
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
|
||||||
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
|
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
||||||
<Button variant="action" @click="newItem(true)">
|
<ButtonGroup>
|
||||||
<Plus stroke-width="1.5" /> Neuer Abschnitt
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="action" @click="newItem(false)">
|
<Button @click="newItem(true)">
|
||||||
<Plus stroke-width="1.5" /> Neue Zeile
|
<Plus /> Neuer Abschnitt
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Popover v-model:open="productsComboboxOpen">
|
<Button @click="newItem(false)">
|
||||||
<PopoverTrigger as-child>
|
<Plus /> Neue Zeile
|
||||||
<Button variant="action" role="combobox" :aria-expanded="productsComboboxOpen"
|
</Button>
|
||||||
:disabled="!products || products.length === 0">
|
|
||||||
<Plus stroke-width="1.5" />
|
|
||||||
Produkt hinzufügen
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent class="w-50 p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Produkt suchen…" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>Kein Produkt gefunden</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem v-for="product in products" :key="product.id" :value="product.id" @select="() => {
|
|
||||||
productsComboboxOpen = false
|
|
||||||
insertProduct(product)
|
|
||||||
}" class="hover:bg-accent">
|
|
||||||
|
|
||||||
<!-- Thumbnail -->
|
<Popover v-model:open="productsComboboxOpen">
|
||||||
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-lg">
|
<PopoverTrigger as-child>
|
||||||
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
|
<Button role="combobox" :aria-expanded="productsComboboxOpen"
|
||||||
class="size-full object-cover dark:brightness-75" loading="lazy" />
|
:disabled="!products || products.length === 0">
|
||||||
<PlaceholderPattern v-else />
|
<Plus />
|
||||||
</div>
|
Produkt hinzufügen
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-50 p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Produkt suchen…" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Kein Produkt gefunden</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem v-for="product in products" :key="product.id" :value="product.id" @select="() => {
|
||||||
|
productsComboboxOpen = false
|
||||||
|
insertProduct(product)
|
||||||
|
}" class="hover:bg-accent">
|
||||||
|
|
||||||
{{ product.title }}
|
<!-- Thumbnail -->
|
||||||
</CommandItem>
|
<div class="w-6 relative aspect-4/3 overflow-hidden rounded-sm shrink-0">
|
||||||
</CommandGroup>
|
<img v-if="product.image" :src="'storage/uploads/products/' + product.image"
|
||||||
</CommandList>
|
class="size-full object-cover dark:brightness-75" loading="lazy" />
|
||||||
</Command>
|
<PlaceholderPattern v-else />
|
||||||
</PopoverContent>
|
</div>
|
||||||
</Popover>
|
|
||||||
|
{{ product.title }}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
</ButtonGroup>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
import { cn } from '@/lib/utils'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { Check } from 'lucide-vue-next'
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
import { Check } from "lucide-vue-next"
|
||||||
import { computed, type HTMLAttributes } from 'vue'
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
const emits = defineEmits<CheckboxRootEmits>()
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CheckboxRoot
|
<CheckboxRoot
|
||||||
data-slot="checkbox"
|
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
props.class)"
|
props.class)"
|
||||||
>
|
>
|
||||||
<CheckboxIndicator
|
<CheckboxIndicator class="grid place-content-center text-current">
|
||||||
data-slot="checkbox-indicator"
|
|
||||||
class="flex items-center justify-center text-current transition-none"
|
|
||||||
>
|
|
||||||
<slot>
|
<slot>
|
||||||
<Check class="size-3.5" />
|
<Check class="h-4 w-4" />
|
||||||
</slot>
|
</slot>
|
||||||
</CheckboxIndicator>
|
</CheckboxIndicator>
|
||||||
</CheckboxRoot>
|
</CheckboxRoot>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { default as Checkbox } from './Checkbox.vue'
|
export { default as Checkbox } from "./Checkbox.vue"
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,9 @@ 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-white [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-desctructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
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",
|
||||||
outline:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
},
|
},
|
||||||
@@ -3,30 +3,31 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
|||||||
export { default as Button } from './Button.vue'
|
export { default as Button } from './Button.vue'
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium shadow-sm active:shadow-none active:inset-shadow-2xs active:inset-shadow-black/15 active:border-none transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium shadow-sm ' +
|
||||||
|
'focusactive:shadow-none active:shadow-none active:inset-shadow-xs active:inset-shadow-black/30 active:border-transparent active:top-[1px] transition-shadow transition-top disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive ' +
|
||||||
|
'text-foreground border-b-1 border-t-1 border-t-white/50 border-b-black/20 dark:border-t-white/30 dark:border-b-black/30',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
'bg-slate-100 hover:bg-slate-200 dark:bg-neutral-700 dark:hover:bg-neutral-700/66 border-b-1 border-t-1 border-t-white/75 border-b-slate-300 dark:border-t-neutral-600 dark:border-b-neutral-800',
|
'bg-zinc-100 hover:bg-zinc-200 active:bg-zinc-300 dark:bg-neutral-600 dark:hover:bg-neutral-700 dark:active:bg-neutral-800',
|
||||||
|
outline:
|
||||||
|
'border shadow-none bg-transparent border-border dark:border-border hover:bg-accent active:bg-zinc-200 dark:hover:bg-input/50',
|
||||||
|
primary:
|
||||||
|
'bg-primary hover:bg-orange-600 active:bg-orange-700 text-primary-foreground',
|
||||||
action:
|
action:
|
||||||
'bg-blue-600 border-b-1 border-t-1 border-t-blue-400 border-b-blue-800 active:bg-blue-700 hover:bg-blue-500 text-white',
|
'bg-action hover:bg-blue-600 active:bg-blue-700 text-action-foreground',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive dark:bg-red-700 text-white border-b-1 border-t-1 border-t-red-200 border-b-red-700 dark:border-t-red-400 dark:border-b-red-800 hover:bg-red-600 active:bg-red-500 active:inset-shadow-red-950 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
'bg-destructive hover:bg-red-600 active:bg-red-700 dark:bg-red-700 text-white',
|
||||||
success:
|
success:
|
||||||
'bg-success text-success-foreground border-b-1 border-t-1 border-t-lime-200 border-b-lime-500 dark:border-t-lime-400 dark:border-b-lime-800 hover:bg-lime-500 active:bg-lime-500 active:inset-shadow-lime-600 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
'bg-success text-success-foreground border-b-1 border-t-1 border-t-lime-200 border-b-lime-500 dark:border-t-lime-400 dark:border-b-lime-800 hover:bg-lime-500 active:bg-lime-500 active:inset-shadow-lime-600 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
||||||
outline:
|
|
||||||
'border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
|
||||||
secondary:
|
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
||||||
ghost:
|
ghost:
|
||||||
'border-none shadow-none hover:bg-slate-100 hover:text-accent-foreground dark:hover:bg-accent/50',
|
'border-none shadow-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
|
||||||
pressed:
|
pressed:
|
||||||
'bg-slate-100 dark:bg-neutral-700 shadow-none inset-shadow-2xs inset-shadow-black/15 border-none',
|
'bg-zinc-100 dark:bg-neutral-700 shadow-none inset-shadow-2xs inset-shadow-black/15 border-none top-[1px]',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-8 px-6 active:pt-[1px] has-[>svg]:px-3',
|
default: 'h-8 px-6 has-[>svg]:px-3',
|
||||||
sm: 'h-7 px-4 rounded-md gap-1.5 has-[>svg]:px-2.5',
|
sm: 'h-7 px-4 rounded-md gap-1.5 has-[>svg]:px-2.5',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
icon: 'size-9',
|
icon: 'size-9',
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { Check } from "lucide-vue-next"
|
||||||
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CheckboxRoot
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn('grid place-content-center peer size-6 shrink-0 rounded-full border-[1.625px] border-border ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-current',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="grid place-content-center text-current">
|
||||||
|
<slot>
|
||||||
|
<Check class="h-3 w-3" stroke-width="3" />
|
||||||
|
</slot>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from "./Checkbox.vue"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { computed, onMounted, onUpdated, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
modelValue?: string | null
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
spellcheck?: false
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const text = ref<string>(props.modelValue || '')
|
||||||
|
const editable = useTemplateRef('editable')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setText(props.modelValue || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(props, () => { setText(props.modelValue || '') }, { deep: true, immediate: false })
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'input:modelValue', payload: string): void
|
||||||
|
(e: 'change:modelValue', payload: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const setText = (value: string) => {
|
||||||
|
if (!editable.value) return
|
||||||
|
editable.value.innerText = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = () => {
|
||||||
|
let newValue = editable.value?.textContent || ''
|
||||||
|
text.value = newValue
|
||||||
|
emits('input:modelValue', newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blur = () => {
|
||||||
|
emits('change:modelValue', text.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = computed(() => {
|
||||||
|
return text.value === undefined || text.value === null || text.value.trim() === ''
|
||||||
|
},)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<span v-if="isEmpty" :class="cn('absolute text-muted-foreground! pointer-events-none italic', props.class)">
|
||||||
|
{{ placeholder }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div ref="editable" contenteditable="plaintext-only" :spellcheck="props.spellcheck" @blur="blur" @input="input"
|
||||||
|
:class="cn(
|
||||||
|
'min-w-60 min-h-4 border-input rounded-md border text-base transition-[color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
|
'my-0 px-2 ml-[calc(var(--spacing)*-2-1px)] hover:bg-input border-transparent hover:border-border', props.class)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Editable } from "./Editable.vue"
|
||||||
@@ -20,14 +20,10 @@ const modelValue = useVModel(props, 'modelValue', emits, {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input v-model="modelValue" data-slot="input" :class="cn(
|
||||||
v-model="modelValue"
|
'file:text-foreground placeholder:text-muted-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-input px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
data-slot="input"
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
:class="cn(
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
'file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-input px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
props.class,
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
)">
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
||||||
props.class,
|
|
||||||
)"
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { isNumeric } from '@/lib/utils';
|
||||||
|
import { component as VueNumber } from '@coders-tm/vue-number-format'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: number | string
|
||||||
|
decimal?: string
|
||||||
|
separator?: string
|
||||||
|
precision?: number
|
||||||
|
minimumFractionDigits?: number
|
||||||
|
maximumFractionDigits?: number
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', value: number): void }>()
|
||||||
|
|
||||||
|
const value = computed<number>({
|
||||||
|
get() {
|
||||||
|
if (typeof props.modelValue === 'string') {
|
||||||
|
const parsedValue = parseFloat(props.modelValue.replace(',', '.'))
|
||||||
|
return isNaN(parsedValue) ? 0 : parsedValue
|
||||||
|
}
|
||||||
|
return (props.modelValue as number) || 0
|
||||||
|
},
|
||||||
|
set(val: any) {
|
||||||
|
const newVal = parseFloat(String(val));
|
||||||
|
const current = typeof props.modelValue === 'string'
|
||||||
|
? parseFloat(props.modelValue.replace(',', '.'))
|
||||||
|
: (props.modelValue as number) || 0;
|
||||||
|
|
||||||
|
if (isNaN(newVal) && isNaN(current)) return;
|
||||||
|
if (newVal === current) return;
|
||||||
|
|
||||||
|
emit('update:modelValue', newVal);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const number = {
|
||||||
|
decimal: props.decimal || ',',
|
||||||
|
separator: props.separator || '.',
|
||||||
|
prefix: props.prefix || '',
|
||||||
|
suffix: props.suffix || '',
|
||||||
|
precision: props.precision || 2,
|
||||||
|
minimumFractionDigits: isNumeric(props.minimumFractionDigits) ? props.minimumFractionDigits : 2,
|
||||||
|
maximumFractionDigits: isNumeric(props.maximumFractionDigits) ? props.maximumFractionDigits : 2,
|
||||||
|
masked: false,
|
||||||
|
} as {
|
||||||
|
decimal: string
|
||||||
|
separator: string
|
||||||
|
prefix: string
|
||||||
|
suffix: string
|
||||||
|
precision: number
|
||||||
|
minimumFractionDigits: number
|
||||||
|
maximumFractionDigits: number
|
||||||
|
masked: boolean
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<vue-number class="file:text-foreground border-input flex min-w-4 rounded-lg transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive tabular-nums h-6 py-0 px-1 m-0 bg-transparent border-none dark:bg-transparent hover:border
|
||||||
|
dark:hover:border placeholder:text-muted-foreground/30 shadow-none w-full text-right" v-model="value" v-bind="number" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SidebarProps } from '.'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||||
|
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||||
|
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||||
|
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||||
|
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||||
|
side: 'left',
|
||||||
|
variant: 'sidebar',
|
||||||
|
collapsible: 'offcanvas',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="collapsible === 'none'"
|
||||||
|
data-slot="sidebar"
|
||||||
|
:class="cn('text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
:side="side"
|
||||||
|
class="text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||||
|
:style="{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<SheetHeader class="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="flex h-full w-full flex-col">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="group peer bg-sidebar text-sidebar-foreground hidden md:block"
|
||||||
|
data-slot="sidebar"
|
||||||
|
:data-state="state"
|
||||||
|
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-side="side"
|
||||||
|
>
|
||||||
|
<!-- This is what handles the sidebar gap on desktop -->
|
||||||
|
<div
|
||||||
|
:class="cn(
|
||||||
|
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-100 ease-linear',
|
||||||
|
'group-data-[collapsible=offcanvas]:w-0',
|
||||||
|
'group-data-[side=right]:rotate-180',
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||||
|
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||||
|
)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
:class="cn(
|
||||||
|
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-100 ease-linear md:flex',
|
||||||
|
side === 'left'
|
||||||
|
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||||
|
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === 'floating' || variant === 'inset'
|
||||||
|
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||||
|
: 'p-2 group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-0 group-data-[side=right]:border-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
class="group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
:class="cn('flex flex-col gap-2 p-2 overflow-x-hidden', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
:class="cn('relative flex w-full min-w-0 flex-col p-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'after:absolute after:-inset-2 md:after:hidden',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
:class="cn('w-full text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||||
|
props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
:class="cn('flex flex-col gap-2 p-2', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
:class="cn(
|
||||||
|
'bg-background h-8 w-full shadow-none',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Input>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
:class="cn(
|
||||||
|
'bg-background relative flex w-full flex-1 flex-col',
|
||||||
|
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
:class="cn('flex w-full min-w-0 flex-col gap-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PrimitiveProps & {
|
||||||
|
showOnHover?: boolean
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>(), {
|
||||||
|
as: 'button',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'after:absolute after:-inset-2 md:after:hidden',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
showOnHover
|
||||||
|
&& 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||||
|
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||||
|
'peer-data-[size=sm]/menu-button:top-1',
|
||||||
|
'peer-data-[size=default]/menu-button:top-1.5',
|
||||||
|
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { type Component, computed } from 'vue'
|
||||||
|
import SidebarMenuButtonChild, { type SidebarMenuButtonProps } from './SidebarMenuButtonChild.vue'
|
||||||
|
import { useSidebar } from './utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
||||||
|
tooltip?: string | Component
|
||||||
|
}>(), {
|
||||||
|
as: 'button',
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
const { tooltip, ...delegated } = props
|
||||||
|
return delegated
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
|
||||||
|
<slot />
|
||||||
|
</SidebarMenuButtonChild>
|
||||||
|
|
||||||
|
<Tooltip v-else>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
|
||||||
|
<slot />
|
||||||
|
</SidebarMenuButtonChild>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
:hidden="state !== 'collapsed' || isMobile"
|
||||||
|
>
|
||||||
|
<template v-if="typeof tooltip === 'string'">
|
||||||
|
{{ tooltip }}
|
||||||
|
</template>
|
||||||
|
<component :is="tooltip" v-else />
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||||
|
import { type SidebarMenuButtonVariants, sidebarMenuButtonVariants } from '.'
|
||||||
|
|
||||||
|
export interface SidebarMenuButtonProps extends PrimitiveProps {
|
||||||
|
variant?: SidebarMenuButtonVariants['variant']
|
||||||
|
size?: SidebarMenuButtonVariants['size']
|
||||||
|
isActive?: boolean
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SidebarMenuButtonProps>(), {
|
||||||
|
as: 'button',
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
:data-size="size"
|
||||||
|
:data-active="isActive"
|
||||||
|
:class="cn(sidebarMenuButtonVariants({ variant, size }), props.class)"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
:class="cn('group/menu-item relative', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { computed, type HTMLAttributes } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
showIcon?: boolean
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const width = computed(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
:class="cn('flex h-8 items-center gap-2 rounded-md px-2', props.class)"
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
v-if="showIcon"
|
||||||
|
class="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Skeleton
|
||||||
|
class="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
:style="{ '--skeleton-width': width }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
:class="cn(
|
||||||
|
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PrimitiveProps & {
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
isActive?: boolean
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>(), {
|
||||||
|
as: 'a',
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:data-size="size"
|
||||||
|
:data-active="isActive"
|
||||||
|
:class="cn(
|
||||||
|
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||||
|
size === 'sm' && 'text-xs',
|
||||||
|
size === 'md' && 'text-sm',
|
||||||
|
'group-data-[collapsible=icon]:hidden',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
:class="cn('group/menu-sub-item relative', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useEventListener, useMediaQuery, useVModel } from '@vueuse/core'
|
||||||
|
import { TooltipProvider } from 'reka-ui'
|
||||||
|
import { computed, type HTMLAttributes, type Ref, ref } from 'vue'
|
||||||
|
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>(), {
|
||||||
|
defaultOpen: true,
|
||||||
|
open: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
'update:open': [open: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||||
|
const openMobile = ref(false)
|
||||||
|
|
||||||
|
const open = useVModel(props, 'open', emits, {
|
||||||
|
defaultValue: props.defaultOpen ?? false,
|
||||||
|
passive: (props.open === undefined) as false,
|
||||||
|
}) as Ref<boolean>
|
||||||
|
|
||||||
|
function setOpen(value: boolean) {
|
||||||
|
open.value = value // emits('update:open', value)
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpenMobile(value: boolean) {
|
||||||
|
openMobile.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
function toggleSidebar() {
|
||||||
|
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener('keydown', (event: KeyboardEvent) => {
|
||||||
|
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = computed(() => open.value ? 'expanded' : 'collapsed')
|
||||||
|
|
||||||
|
provideSidebarContext({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider :delay-duration="0">
|
||||||
|
<!-- :class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)" -->
|
||||||
|
<!-- :class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-linear-45 from-orange-200 to-orange-300 flex min-h-svh w-full', props.class)" -->
|
||||||
|
<!-- linear-[45deg,rgb(235,217,178)_0%,_rgb(237,183,83)_100%] -->
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
:style="{
|
||||||
|
'--sidebar-width': SIDEBAR_WIDTH,
|
||||||
|
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||||
|
}"
|
||||||
|
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useSidebar } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
:tabindex="-1"
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
:class="cn(
|
||||||
|
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||||
|
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||||
|
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||||
|
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||||
|
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||||
|
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
:class="cn('bg-sidebar-border mx-2 w-auto', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Separator>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { PanelLeft } from 'lucide-vue-next'
|
||||||
|
import { useSidebar } from './utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { toggleSidebar, open } = useSidebar()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:class="cn('h-7 w-7', props.class)"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<PanelLeft :class="open ? 'text-primary' : 'text-sidebar-icon'" />
|
||||||
|
<span class="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
side?: 'left' | 'right'
|
||||||
|
variant?: 'sidebar' | 'floating' | 'inset'
|
||||||
|
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
export { default as Sidebar } from './Sidebar.vue'
|
||||||
|
export { default as SidebarContent } from './SidebarContent.vue'
|
||||||
|
export { default as SidebarFooter } from './SidebarFooter.vue'
|
||||||
|
export { default as SidebarGroup } from './SidebarGroup.vue'
|
||||||
|
export { default as SidebarGroupAction } from './SidebarGroupAction.vue'
|
||||||
|
export { default as SidebarGroupContent } from './SidebarGroupContent.vue'
|
||||||
|
export { default as SidebarGroupLabel } from './SidebarGroupLabel.vue'
|
||||||
|
export { default as SidebarHeader } from './SidebarHeader.vue'
|
||||||
|
export { default as SidebarInput } from './SidebarInput.vue'
|
||||||
|
export { default as SidebarInset } from './SidebarInset.vue'
|
||||||
|
export { default as SidebarMenu } from './SidebarMenu.vue'
|
||||||
|
export { default as SidebarMenuAction } from './SidebarMenuAction.vue'
|
||||||
|
export { default as SidebarMenuBadge } from './SidebarMenuBadge.vue'
|
||||||
|
export { default as SidebarMenuButton } from './SidebarMenuButton.vue'
|
||||||
|
export { default as SidebarMenuItem } from './SidebarMenuItem.vue'
|
||||||
|
export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue'
|
||||||
|
export { default as SidebarMenuSub } from './SidebarMenuSub.vue'
|
||||||
|
export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue'
|
||||||
|
export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue'
|
||||||
|
export { default as SidebarProvider } from './SidebarProvider.vue'
|
||||||
|
export { default as SidebarRail } from './SidebarRail.vue'
|
||||||
|
export { default as SidebarSeparator } from './SidebarSeparator.vue'
|
||||||
|
export { default as SidebarTrigger } from './SidebarTrigger.vue'
|
||||||
|
|
||||||
|
export { useSidebar } from './utils'
|
||||||
|
|
||||||
|
export const sidebarMenuButtonVariants = cva(
|
||||||
|
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:pr-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
outline:
|
||||||
|
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-8 text-sm',
|
||||||
|
sm: 'h-7 text-xs',
|
||||||
|
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { createContext } from 'reka-ui'
|
||||||
|
|
||||||
|
export const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
||||||
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
export const SIDEBAR_WIDTH = '14rem'
|
||||||
|
export const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||||
|
export const SIDEBAR_WIDTH_ICON = '4rem'
|
||||||
|
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||||
|
|
||||||
|
export const [useSidebar, provideSidebarContext] = createContext<{
|
||||||
|
state: ComputedRef<'expanded' | 'collapsed'>
|
||||||
|
open: Ref<boolean>
|
||||||
|
setOpen: (value: boolean) => void
|
||||||
|
isMobile: Ref<boolean>
|
||||||
|
openMobile: Ref<boolean>
|
||||||
|
setOpenMobile: (value: boolean) => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}>('Sidebar')
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- <div class="relative w-full overflow-auto"> -->
|
||||||
|
<table :class="cn('w-full relative caption-bottom', props.class)">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
<!-- </div> -->
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tbody :class="cn('[&_tr:last-child]:border-0 overflow-clip rounded-lg print:rounded-none shadow print:shadow-none', props.class)">
|
||||||
|
<slot />
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<caption :class="cn('mt-4 text-sm text-muted-foreground', props.class)">
|
||||||
|
<slot />
|
||||||
|
</caption>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import TableCell from "./TableCell.vue"
|
||||||
|
import TableRow from "./TableRow.vue"
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
colspan?: number
|
||||||
|
}>(), {
|
||||||
|
colspan: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tfoot :class="cn('font-medium [&>tr]:border-none [&>tr]:bg-transparent [&>tr]:hover:bg-transparent [&_tr]:dark:hover:bg-transparent', props.class)">
|
||||||
|
<slot />
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
|
||||||
|
<slot />
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<thead :class="cn('[&_tr]:border-none [&_tr]:bg-main [&_tr]:hover:bg-transparent [&_tr]:dark:hover:bg-transparent', props.class)">
|
||||||
|
<slot />
|
||||||
|
</thead>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr :class="cn('hover:bg-accent hover:dark:bg-background/75 border-b transition-colors data-[state=selected]:bg-muted select-none md:select-auto cursor-default bg-background', props.class)">
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { default as Table } from "./Table.vue"
|
||||||
|
export { default as TableBody } from "./TableBody.vue"
|
||||||
|
export { default as TableCaption } from "./TableCaption.vue"
|
||||||
|
export { default as TableCell } from "./TableCell.vue"
|
||||||
|
export { default as TableEmpty } from "./TableEmpty.vue"
|
||||||
|
export { default as TableFooter } from "./TableFooter.vue"
|
||||||
|
export { default as TableHead } from "./TableHead.vue"
|
||||||
|
export { default as TableHeader } from "./TableHeader.vue"
|
||||||
|
export { default as TableRow } from "./TableRow.vue"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Updater } from "@tanstack/vue-table"
|
||||||
|
|
||||||
|
import type { Ref } from "vue"
|
||||||
|
import { isFunction } from "@tanstack/vue-table"
|
||||||
|
|
||||||
|
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||||
|
ref.value = isFunction(updaterOrValue)
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ const delegatedProps = reactiveOmit(props, "class")
|
|||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/20 dark:bg-black/50', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { component as VueNumber } from '@coders-tm/vue-number-format'
|
|
||||||
import { defineComponent, HTMLAttributes } from 'vue'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
components: {
|
|
||||||
VueNumber,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
emits: ['update:modelValue'],
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
value: {
|
|
||||||
get(): number {
|
|
||||||
if (typeof this.modelValue === 'string') {
|
|
||||||
const parsedValue = parseFloat(this.modelValue.replace(',', '.'))
|
|
||||||
return isNaN(parsedValue) ? 0 : parsedValue
|
|
||||||
}
|
|
||||||
return this.modelValue || 0
|
|
||||||
},
|
|
||||||
set(value: number) {
|
|
||||||
this.$emit('update:modelValue', value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
number: {
|
|
||||||
decimal: ',',
|
|
||||||
separator: '.',
|
|
||||||
prefix: '',
|
|
||||||
suffix: ' €',
|
|
||||||
precision: 2,
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
masked: false,
|
|
||||||
} as {
|
|
||||||
decimal: string;
|
|
||||||
separator: string;
|
|
||||||
prefix: string;
|
|
||||||
suffix: string;
|
|
||||||
precision: number;
|
|
||||||
minimumFractionDigits: number;
|
|
||||||
maximumFractionDigits: number;
|
|
||||||
masked: boolean;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<vue-number class="file:text-foreground border-input flex min-w-4 rounded-lg text-base transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive tabular-nums h-6 py-0 px-1 m-0 bg-transparent border-none dark:bg-transparent hover:border-1
|
|
||||||
dark:hover:border-1 placeholder:text-muted-foreground/30 shadow-none w-28 text-right" v-model="value"
|
|
||||||
v-bind="number" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -29,7 +29,7 @@ const delegatedProps = reactiveOmit(props, "class")
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<ProgressIndicator
|
<ProgressIndicator
|
||||||
class="h-full w-full flex-1 bg-primary-foreground transition-all"
|
class="h-full w-full flex-1 bg-current transition-all"
|
||||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||||
/>
|
/>
|
||||||
</ProgressRoot>
|
</ProgressRoot>
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ const delegatedProps = reactiveOmit(props, "class")
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Separator
|
<Separator
|
||||||
data-slot="separator-root"
|
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
'shrink-0 bg-border',
|
||||||
|
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ const forwarded = useForwardPropsEmits(props, emits)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogRoot v-bind="forwarded">
|
<DialogRoot
|
||||||
<slot />
|
v-slot="slotProps"
|
||||||
|
data-slot="sheet"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ const props = defineProps<DialogCloseProps>()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogClose v-bind="props">
|
<DialogClose
|
||||||
|
data-slot="sheet-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,31 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue"
|
||||||
import type { SheetVariants } from "."
|
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next"
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { sheetVariants } from "."
|
import SheetOverlay from "./SheetOverlay.vue"
|
||||||
|
|
||||||
interface SheetContentProps extends DialogContentProps {
|
interface SheetContentProps extends DialogContentProps {
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"]
|
||||||
side?: SheetVariants["side"]
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<SheetContentProps>()
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
|
side: "right",
|
||||||
|
})
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "side")
|
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||||
@@ -35,19 +33,29 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay
|
<SheetOverlay />
|
||||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
||||||
/>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
:class="cn(sheetVariants({ side }), props.class)"
|
data-slot="sheet-content"
|
||||||
v-bind="{ ...forwarded, ...$attrs }"
|
:class="cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
side === 'right'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||||
|
side === 'left'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
|
side === 'top'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
side === 'bottom'
|
||||||
|
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
|
props.class)"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4 text-muted-foreground" />
|
<X class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const delegatedProps = reactiveOmit(props, "class")
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogDescription
|
<DialogDescription
|
||||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
data-slot="sheet-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="
|
data-slot="sheet-footer"
|
||||||
cn(
|
:class="cn('mt-auto flex flex-col gap-2 p-4', props.class)
|
||||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="
|
data-slot="sheet-header"
|
||||||
cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)
|
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cn } from '@/lib/utils'
|
import type { DialogOverlayProps } from "reka-ui"
|
||||||
import { DialogOverlay, type DialogOverlayProps } from 'reka-ui'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { computed, type HTMLAttributes } from 'vue'
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
|
import { DialogOverlay } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||||
|
|
||||||
const delegatedProps = computed(() => {
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
const { class: _, ...delegated } = props
|
|
||||||
|
|
||||||
return delegated
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const delegatedProps = reactiveOmit(props, "class")
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
:class="cn('text-lg font-semibold text-foreground', props.class)"
|
data-slot="sheet-title"
|
||||||
|
:class="cn('text-foreground font-semibold', props.class)"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user