dataflow #180

Merged
vollstock merged 6 commits from dataflow into main 2026-02-17 10:38:35 +01:00
182 changed files with 6493 additions and 1723 deletions
+1
View File
@@ -26,3 +26,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
/.continue
@@ -33,6 +33,31 @@ public function handle(CaldavService $service)
Todo::upsert($todo->attributesToArray(), 'id');
$count++;
}
// Collect hrefs/URLs returned by the CalDAV server so we can remove local
// todos that belong to this calendar but were deleted on the server.
$hrefs = array_values(array_filter(array_map(function ($t) {
return $t->url ?? null;
}, $todos)));
// Remove local todos that have a URL in this calendar but weren't returned
// by the server (i.e. they were deleted remotely). Scope deletion by
// calendar prefix to avoid touching other calendars.
$calendarId = $service->getCalendarId();
$username = config('caldav.username') ?? '';
$calendarPrefix = 'calendars/' . $username . '/' . $calendarId;
if ($calendarId && $username) {
Todo::whereNotNull('url')
->where('url', 'like', '%' . $calendarPrefix . "%")
->whereNotIn('url', $hrefs ?: [''])
->delete();
}
// Remove old todos
Todo::where('status', 'COMPLETED')
->where('last_modified', '<', now()->subDays(30)) // TODO: get from settings
->where('due_date', '<', now()->subDays(30))
->delete();
Log::info("Synced " . count($todos) . " todos.");
$this->info("Synced " . count($todos) . " todos.");
+5 -5
View File
@@ -170,15 +170,15 @@ public function salesStatistics()
'year' => $currentYear,
'totalRevenue' => $totalRevenue,
'paid' => $paid,
'paidPercent' => round(($paid / $totalRevenue) * 100, 2),
'paidPercent' => ($totalRevenue == 0) ? 0 : round(($paid / $totalRevenue) * 100, 2),
'draft' => $draft,
'draftPercent' => round(($draft / $totalRevenue) * 100, 2),
'draftPercent' => ($totalRevenue == 0) ? 0 : round(($draft / $totalRevenue) * 100, 2),
'issued' => $issued,
'issuedPercent' => round(($issued / $totalRevenue) * 100, 2),
'issuedPercent' => ($totalRevenue == 0) ? 0 : round(($issued / $totalRevenue) * 100, 2),
'due' => $due,
'duePercent' => round(($due / $totalRevenue) * 100, 2),
'duePercent' => ($totalRevenue == 0) ? 0 : round(($due / $totalRevenue) * 100, 2),
'reminded' => $reminded,
'remindedPercent' => round(($reminded / $totalRevenue) * 100, 2),
'remindedPercent' => ($totalRevenue == 0) ? 0 : round(($reminded / $totalRevenue) * 100, 2),
];
// Daten in camelCase umwandeln
+13 -27
View File
@@ -57,10 +57,9 @@ public function index($invoiceId)
* ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00
*
* @param Request $request The HTTP request with the CSV file
* @param int $invoiceId The ID of the invoice for which the items will be imported
* @return \Illuminate\Http\JsonResponse A JSON response with the imported items or an error message
*/
public function importFromCsv(Request $request, $invoiceId)
public function importFromCsv(Request $request)
{
$request->validate([
'csv' => 'required|file|mimes:csv,txt'
@@ -133,16 +132,22 @@ public function importFromCsv(Request $request, $invoiceId)
}
$lineItems[] = [
'invoice_id' => $invoiceId,
'id' => 0,
'invoice_id' => 0,
'position' => $position,
'is_section' => false,
'title' => $itemData['title'],
'description' => $itemData['description'] ?? '',
'quantity' => (float)$quantity,
'unit_id' => $unit->id,
'unit' => [
'id' => $unit->id,
'name' => $unit->name,
'symbol' => $unit->symbol,
'created_at' => $unit->created_at,
'updated_at' => $unit->updated_at,
],
'price' => (float)$price,
'created_at' => now(),
'updated_at' => now()
];
}
@@ -150,28 +155,9 @@ public function importFromCsv(Request $request, $invoiceId)
return response()->json(['message' => 'No valid items found in the CSV file'], 400);
}
// Delete existing items for this invoice
LineItem::where('invoice_id', $invoiceId)->delete();
// Insert new items
LineItem::insert($lineItems);
// Return the newly created items
$items = LineItem::with('unit')
->where('invoice_id', $invoiceId)
->orderBy('position', 'asc')
->get();
return $items->map(function ($item) {
$itemArray = $item->toArray();
if ($item->unit) {
$itemArray['unit_name'] = $item->unit->name;
$itemArray['unit_symbol'] = $item->unit->symbol;
}
return ApiDataTransformer::snakeToCamel($itemArray);
});
return response()->json(
ApiDataTransformer::snakeToCamel($lineItems)
);
}
/**
+21 -31
View File
@@ -11,14 +11,14 @@ class NoteController extends Controller
{
/**
* Display a listing of the resource.
* @param Request $request
* @param string $modelType The type of the model (e.g., 'Customer', 'Invoice')
* @param string $modelId The ID of the model
* @return \Illuminate\Http\JsonResponse
*/
public function index(Request $request, $modelId)
public function index(Request $request, string $modelType, int $modelId)
{
$modelType = $request->route()->parameter('modelType');
if (!$modelType) {
$modelType = $request->route()->getAction('modelType');
}
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
$model = app("App\\Models\\" . $modelType)::findOrFail($modelId);
// Lade alle Notizen des Modells mit der Benutzerbeziehung
$notes = $model->notes()->with('user')->orderBy('created_at', 'desc')->get();
@@ -34,24 +34,30 @@ public function index(Request $request, $modelId)
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, $modelId)
public function store(Request $request)
{
$modelType = $request->route()->parameter('modelType');
if (!$modelType) {
$modelType = $request->route()->getAction('modelType');
}
$validatedData = $request->validate([
'userId' => 'required|integer|exists:users,id',
'text' => 'required|string',
'userId' => 'required|integer'
'noteableId' => 'required|integer',
'noteableType' => 'required|string',
'createdAt' => 'sometimes|date|nullable'
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
$note = new Note($validatedData);
$note->user_id = $validatedData['userId'];
$model = app("App\\Models\\" . $snakeCaseData['noteable_type'])::findOrFail($snakeCaseData['noteable_id']);
// Create a new Note instance
$note = new Note($snakeCaseData);
// Set the created_at field if it is provided
if (isset($snakeCaseData['created_at'])) {
$note->created_at = $snakeCaseData['created_at'];
}
$model->notes()->save($note);
return response()->json($this->single($note->id), 201);
@@ -64,22 +70,6 @@ public static function single($id)
}
/**
* Display the specified resource.
*/
public function show(Note $note)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Note $note)
{
//
}
/**
* Update the specified resource in storage.
*/
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\PipelineLane;
use App\Models\PipelineItem;
use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Inertia\Inertia;
class PipelineController extends Controller
{
public function index()
{
$lanes = PipelineLane::with(['items' => function ($q) {
$q->withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position');
}])->orderBy('position')->get();
return $lanes->map(function ($lane) {
return ApiDataTransformer::snakeToCamel($lane->toArray());
});
}
public function show()
{
return Inertia::render('Pipeline', [
'pipeline' => $this->index(),
]);
}
/**
* Update positions in bulk. Expect array of objects with camelCase keys: id, stage, position
*/
public function updatePositions(Request $request)
{
$payload = $request->all();
if (!is_array($payload)) {
return response()->json(['message' => 'Invalid payload'], 422);
}
$items = array_map(function ($item) {
return ApiDataTransformer::camelToSnake((array)$item);
}, $payload);
$validator = Validator::make(['items' => $items], [
'items.*.id' => 'required|integer|exists:pipeline_items,id',
'items.*.pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id',
'items.*.title' => 'string',
'items.*.position' => 'required|integer|min:0',
'items.*.expected_revenue' => 'decimal',
'items.*.description' => 'string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
DB::beginTransaction();
try {
foreach ($items as $it) {
PipelineItem::where('id', $it['id'])->update([
'pipeline_lane_id' => $it['pipeline_lane_id'],
'position' => $it['position'],
]);
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Failed to update pipeline positions: ' . $e->getMessage());
return response()->json(['message' => 'Failed to update positions'], 500);
}
return response()->json(['status' => 'ok']);
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers;
use App\Models\PipelineItem;
use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
class PipelineItemController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$pipelineItems = PipelineItem::withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position')->get();
return ApiDataTransformer::snakeToCamel($pipelineItems->toArray());
}
/**
* Display a listing of the resource.
*/
public function single(int $id)
{
$pipelineItem = PipelineItem::withCount(['notes' => function ($query) {
$query->where('notable_type', 'App\Models\PipelineItem');
}])->orderBy('position')->findOrFail($id);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validatedData = $request->validate([
'pipeline_lane_id' => 'required|integer|exists:pipeline_lanes,id',
'title' => 'required|string',
'position' => 'required|integer|min:0',
'expected_revenue' => 'nullable|numeric',
'due_date' => 'nullable|date',
'description' => 'nullable|string',
]);
$pipelineItem = PipelineItem::create($validatedData);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, PipelineItem $pipelineItem)
{
$validatedData = $request->validate([
'pipeline_lane_id' => 'sometimes|integer|exists:pipeline_lanes,id',
'title' => 'sometimes|string',
'position' => 'sometimes|integer|min:0',
'expected_revenue' => 'nullable|numeric',
'due_date' => 'nullable|date',
'description' => 'nullable|string',
]);
$pipelineItem->update($validatedData);
return ApiDataTransformer::snakeToCamel($pipelineItem->toArray());
}
/**
* Remove the specified resource from storage.
*/
public function delete(int $id)
{
PipelineItem::findOrFail($id)->delete();
return response()->noContent();
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use Inertia\Inertia;
use App\Models\Timesheet;
use Illuminate\Http\Request;
use App\Support\ApiDataTransformer;
class TimesheetController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$timesheets = Timesheet::with(['entries' => function ($query) {
$query->selectRaw('
timesheet_id,
SUM(hours) as total_hours,
SUM(CASE WHEN billed = 1 THEN hours ELSE 0 END) as hours_billed,
MIN(date) as earliest_date,
MAX(date) as latest_date
')
->groupBy('timesheet_id');
}])->get();
// Sort timesheets by the updated_at timestamp of the latest entry
$timesheets = $timesheets->sortByDesc(function ($timesheet) {
$latestEntry = $timesheet->entries()->latest('updated_at')->first();
return $latestEntry ? $latestEntry->updated_at : $timesheet->updated_at;
})->values();
$snakeCaseData = $timesheets->map(function ($timesheet) {
$entryStats = $timesheet->entries->first();
return [
'id' => $timesheet->id,
'title' => $timesheet->title,
'total_hours' => $entryStats->total_hours ?? 0,
'hours_billed' => $entryStats->hours_billed ?? 0,
'earliest_date' => $entryStats->earliest_date ?? null,
'latest_date' => $entryStats->latest_date ?? null,
'created_at' => $timesheet->created_at->toISOString(),
'updated_at' => $timesheet->updated_at->toISOString(),
];
});
return ApiDataTransformer::snakeToCamel($snakeCaseData->toArray());
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
]);
$snakeCaseData = Timesheet::create($validated);
return ApiDataTransformer::snakeToCamel($snakeCaseData->toArray());
}
/**
* Display the specified resource.
*/
public function show(Timesheet $timesheet)
{
return Inertia::render('Timesheets', [
'timesheetData' => $this->index()
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Timesheet $timesheet)
{
$validatedData = $request->validate([
'title' => 'required|string|max:255',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$timesheet->update($snakeCaseData);
return $timesheet;
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Timesheet $timesheet)
{
$timesheet->delete();
return response()->noContent();
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Models\TimesheetEntry;
use App\Models\Timesheet;
use Illuminate\Http\Request;
use App\Support\ApiDataTransformer;
class TimesheetEntryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$entries = TimesheetEntry::with(['timesheet', 'user'])->orderBy('date', 'asc')->get();
return $entries->map(function ($entry) {
return ApiDataTransformer::snakeToCamel($entry->toArray());
});
}
/**
* Get all entries for a specific timesheet
*/
public function getEntriesForTimesheet(Timesheet $timesheet)
{
$entries = $timesheet->entries()->with('user')->orderBy('date', 'asc')->get();
return $entries->map(function ($entry) {
return ApiDataTransformer::snakeToCamel($entry->toArray());
});
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validatedData = $request->validate([
'timesheetId' => 'required|exists:timesheets,id',
'date' => 'required|date',
'userId' => 'required|exists:users,id',
'description' => 'nullable|string',
'hours' => 'required|numeric',
'billed' => 'sometimes|boolean',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$entry = TimesheetEntry::create($snakeCaseData);
$entry = TimesheetEntry::with(['timesheet', 'user'])->find($entry->id);
return ApiDataTransformer::snakeToCamel($entry->toArray());
}
/**
* Display the specified resource.
*/
public function show(TimesheetEntry $timesheetEntry)
{
$entry = $timesheetEntry->load(['timesheet', 'user']);
return ApiDataTransformer::snakeToCamel($entry->toArray());
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, TimesheetEntry $timesheetEntry)
{
$validatedData = $request->validate([
'timesheetId' => 'sometimes|exists:timesheets,id',
'date' => 'sometimes|date',
'userId' => 'sometimes|exists:users,id',
'description' => 'nullable|string',
'hours' => 'sometimes|numeric',
'billed' => 'sometimes|boolean',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$timesheetEntry->update($snakeCaseData);
return ApiDataTransformer::snakeToCamel($timesheetEntry->toArray());
}
/**
* Remove the specified resource from storage.
*/
public function destroy(TimesheetEntry $timesheetEntry)
{
$timesheetEntry->delete();
return response()->noContent();
}
public function toggleBilled(TimesheetEntry $timesheetEntry)
{
$timesheetEntry = $timesheetEntry->update(['billed' => !$timesheetEntry->billed]);
return ApiDataTransformer::snakeToCamel($timesheetEntry->toArray());
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class TodoController extends Controller
{
public function index()
{
$todos = Todo::with('type')->get();
$todos = Todo::with('type')->orderBy('due_date')->get();
return ApiDataTransformer::snakeToCamel($todos->toArray());
}
+1 -1
View File
@@ -2,8 +2,8 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Customer extends Model
{
+6
View File
@@ -20,6 +20,12 @@ class LineItem extends Model
'price',
];
protected $casts = [
'is_section' => 'boolean',
'quantity' => 'integer',
'price' => 'decimal:2',
];
public function invoice()
{
return $this->belongsTo(Invoice::class);
+2
View File
@@ -22,6 +22,8 @@ public function user()
return $this->belongsTo(User::class);
}
// Polymorphic relationship to the notable model (e.g., Lead, Contact, PipelineItem, etc.)
// https://laravel.com/docs/12.x/eloquent-relationships#one-to-many-polymorphic-relations
public function notable()
{
return $this->morphTo();
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PipelineItem extends Model
{
use HasFactory;
protected $table = 'pipeline_items';
protected $fillable = [
'pipeline_lane_id',
'title',
'position',
'expected_revenue',
'due_date',
'description'
];
protected $casts = [
'next_action' => 'date',
'expected_revenue' => 'decimal:2',
'actions' => 'integer',
'position' => 'integer',
];
public function pipelineLane()
{
return $this->belongsTo(PipelineLane::class);
}
/**
* Get the notes
*/
public function notes()
{
return $this->morphMany(Note::class, 'notable');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class PipelineLane extends Model
{
use HasFactory;
protected $fillable = [
'title',
'position',
];
protected $casts = [
'position' => 'integer',
];
/**
* Get the pipeline items for the lane
*/
public function items()
{
return $this->hasMany(PipelineItem::class);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Timesheet extends Model
{
protected $fillable = ['title'];
public function entries(): HasMany
{
return $this->hasMany(TimesheetEntry::class);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TimesheetEntry extends Model
{
protected $fillable = [
'timesheet_id',
'date',
'user_id',
'description',
'hours',
'billed'
];
protected $casts = [
'date' => 'datetime',
'billed' => 'boolean',
'hours' => 'float'
];
public function timesheet(): BelongsTo
{
return $this->belongsTo(Timesheet::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+4
View File
@@ -34,8 +34,12 @@ class User extends Authenticatable
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
];
/**
* Get the attributes that should be cast.
*
@@ -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');
}
};
+1405 -530
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -32,6 +32,9 @@
"@coders-tm/vue-number-format": "^3.35.4",
"@inertiajs/vue3": "^2.1.0",
"@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",
"axios": "^1.12.2",
"camelcase-keys": "^10.0.0",
@@ -41,7 +44,7 @@
"laravel-vite-plugin": "^2.0.0",
"lucide-vue-next": "^0.468.0",
"pinia": "^3.0.3",
"reka-ui": "^2.6.1",
"reka-ui": "^2.8.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
+79 -36
View File
@@ -45,6 +45,9 @@ @theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-action: var(--action);
--color-action-foreground: var(--action-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -57,6 +60,7 @@ @theme inline {
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-icon: var(--sidebar-icon);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
@@ -74,7 +78,7 @@ @theme inline {
/* https://typescale.com/ /*
/* Major Third */
--text-4xl: 3.815rem;
/* --text-4xl: 3.815rem;
--text-3xl: 3.052rem;
--text-2xl: 2.441rem;
--text-xl: 1.953rem;
@@ -82,12 +86,22 @@ @theme inline {
--text-md: 1.25rem;
--text-base: 1rem;
--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)
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
@@ -103,7 +117,7 @@ @layer base {
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
border-color: var(--color-zinc-200, currentColor);
}
}
@@ -112,11 +126,31 @@ @layer utilities {
body,
html {
--font-sans: system-ui, sans-serif;
font-size: 18px;
background-color: var(--sidebar-background);
/* background: linear-gradient(45deg, var(--color-slate-100), var(--color-orange-100)); */
font-size: 16px;
background-color: var(--main-background);
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 {
@@ -126,14 +160,14 @@ :root {
--card-foreground: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 3.9%);
--popover: var(--color-background);
--popover-foreground: var(--color-gray-900);
--primary: var(--color-orange-200);
--primary-foreground: var(--color-orange-600);
--popover-foreground: var(--color-zinc-900);
--primary: var(--color-orange-500);
--primary-foreground: var(--color-white);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: var(--color-gray-50);
--muted-foreground: var(--color-gray-400);
--accent: var(--color-gray-50);
--muted: var(--color-zinc-50);
--muted-foreground: var(--color-zinc-500);
--accent: var(--color-zinc-100);
--accent-foreground: hsl(0 0% 9%);
--destructive: var(--color-red-500);
--destructive-foreground: hsl(0 0% 98%);
@@ -141,26 +175,29 @@ :root {
--success-foreground: var(--color-foreground);
--warning: var(--color-amber-300);
--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);
--ring: hsl(0 0% 3.9%);
--ring: var(--color-zinc-200);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(313, 58%, 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--main-background: transparent;
--sidebar-background: var(--color-slate-100);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--main-background: var(--color-zinc-50);
--sidebar-background: oklch(95.5% 0.003 286.35);;
--sidebar-foreground: var(--foreground);
--sidebar-icon: var(--color-zinc-500);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: var(--color-white);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: var(--color-gray-300);
--sidebar-accent-foreground: var(--color-foreground);
--sidebar-border: oklch(84% 0.008 286.2);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
--status-draft: var(--color-gray-100);
--status-draft: var(--color-zinc-100);
--status-sent: var(--color-sky-400);
--status-paid: var(--color-lime-500);
--status-due: var(--color-warning);
@@ -170,27 +207,28 @@ :root {
}
.dark {
--background: var(--color-neutral-800);
--background: oklch(24% 0 0);
--foreground: var(--color-neutral-300);
--card: var(--color-neutral-800);
--card-foreground: var(--color-neutral-300);
/* --popover: hsl(0 0% 3.9%); */
--popover: var(--color-neutral-900);
--popover-foreground: var(--color-neutral-300);
--primary: var(--color-orange-300);
--primary-foreground: var(--color-orange-400);
--primary: var(--color-amber-600);
--primary-foreground: var(--color-amber-200);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: var(--color-neutral-300);
--muted: var(--color-neutral-700);
--muted-foreground: var(--color-neutral-500);
--accent: oklch(25% 0 0);
--accent: var(--color-neutral-800);
--accent-foreground: var(--color-neutral-300);
--destructive: var(--color-red-600);
--destructive-foreground: var(--color-red-200);
--success: var(--color-lime-900);
--success-foreground: var(--color-lime-400);
--destructive-foreground: var(--color-red-100);
--success: var(--color-lime-700);
--success-foreground: var(--color-lime-200);
--warning: var(--color-amber-900);
--warning-foreground: var(--color-amber-400);
--action: var(--color-blue-600);
--action-foreground: var(--color-blue-200);
--border: var(--color-neutral-700);
--input: var(--color-neutral-700);
--ring: var(--color-neutral-500);
@@ -199,17 +237,18 @@ .dark {
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--main-background: transparent;
--sidebar-background: var(--color-neutral-700);
--sidebar-foreground: hsl(0 0% 95.9%);
--main-background: oklch(29% 0 0);
--sidebar-background: var(--color-neutral-800);
--sidebar-foreground: var(--foreground);
--sidebar-icon: var(--color-neutral-500);
--sidebar-primary: hsl(360, 100%, 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-border: var(--color-neutral-600);
--sidebar-border: var(--color-neutral-700);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--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-paid: var(--color-lime-700);
--status-due: var(--color-warning);
@@ -226,6 +265,10 @@ @layer base {
body {
@apply bg-background text-foreground;
}
.lucide {
stroke-width: 1.666;
}
}
@layer components {
@@ -251,6 +294,6 @@ @layer utilities {
/* Backdrop */
[data-slot=dialog-overlay] {
backdrop-filter: blur(2px);
backdrop-filter: blur(0.75px);
}
}
+17 -23
View File
@@ -1,16 +1,19 @@
<script setup lang="ts">
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/sidebar';
import { dashboard, crm, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes';
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/crm-sidebar';
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 { Kbd, KbdGroup } from '@/components/ui/kbd'
import { type NavGroup } from '@/types';
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 { computed } from 'vue';
const { open } = useSidebar()
const page = usePage();
const auth = computed(() => page.props.auth);
@@ -20,27 +23,23 @@ const mainNavGroups: NavGroup[] = [
items: [
{
title: 'Pipeline',
href: crm(),
href: pipeline(),
icon: Kanban,
color: 'text-pink-500',
},
{
title: 'Akquise',
href: leads(),
icon: Headset,
color: 'text-blue-500',
},
{
title: 'Kunden',
href: customers(),
icon: BookUser,
color: 'text-lime-500',
icon: ContactRound,
},
{
title: 'Erfolge',
href: achievements(),
icon: Trophy,
color: 'text-amber-500',
},
],
},
@@ -51,31 +50,26 @@ const mainNavGroups: NavGroup[] = [
title: 'Angebote',
href: offers(),
icon: Calculator,
color: 'text-cyan-600',
},
{
title: 'Produkte',
href: products(),
icon: ShoppingBasket,
color: 'text-yellow-400',
},
{
title: 'Rechnungen',
href: invoices(),
icon: Euro,
color: 'text-pink-700',
action: {
title: "Neue Rechnung",
icon: Plus,
color: 'text-foreground',
href: newInvoice()
}
},
{
title: 'Produkte',
href: products(),
icon: Package,
},
{
title: 'Zeiterfassung',
href: timesheets(),
icon: Timer,
color: 'text-lime-600',
},
],
}
@@ -84,7 +78,7 @@ const mainNavGroups: NavGroup[] = [
</script>
<template>
<Sidebar collapsible="icon" variant="sidebar">
<Sidebar collapsible="icon">
<SidebarHeader>
<Link :href="dashboard()" prefetch class="flex row items-center mt-3">
@@ -93,11 +87,11 @@ const mainNavGroups: NavGroup[] = [
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="w-fit absolute top-9 right-[.666rem]">
<SidebarTrigger class="hidden md:flex text-primary-foreground" />
<TooltipTrigger class="w-fit absolute top-9" :class="open ? 'right-[.666rem]' : 'right-[1.14333rem]'">
<SidebarTrigger class="hidden md:flex" />
</TooltipTrigger>
<TooltipContent>
<span>Seitenleiste schließen</span>
<span>Seitenleiste </span>
<KbdGroup class="ml-2">
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
+12 -12
View File
@@ -248,8 +248,8 @@ const deleteNote = async (id: number) => {
<!-- Save -->
<Button v-if="customer && isDirty" class="grow md:grow-0" size="sm" @click="save"
:disabled="isSaving">
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
<Check v-else stroke-width="1.5" />
<Loader2 v-if="isSaving" class="animate-spin" />
<Check v-else />
Speichern
</Button>
@@ -257,7 +257,7 @@ const deleteNote = async (id: number) => {
<DropdownMenu v-if="customer && customer.id > 0">
<DropdownMenuTrigger>
<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>
</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!"
@click="">
<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>
</div>
</DropdownMenuItem>
@@ -449,7 +449,7 @@ const deleteNote = async (id: number) => {
<div class="flex justify-between items-center mb-6">
<h2 class="font-bold">Nächste Schritte</h2>
<Button variant="ghost">
<Plus stroke-width="1.5" />
<Plus />
</Button>
</div>
@@ -468,11 +468,11 @@ const deleteNote = async (id: number) => {
<div class="flex items-center gap-2">
<Input v-model="todo.text"
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" />
<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" />
<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" />
</div>
@@ -490,7 +490,7 @@ const deleteNote = async (id: number) => {
<div class="flex justify-between items-center mb-6">
<h2 class="font-bold">Notizen</h2>
<Button variant="ghost" @click="isTakingNote = true">
<Plus stroke-width="1.5" />
<Plus />
</Button>
</div>
@@ -505,11 +505,11 @@ const deleteNote = async (id: number) => {
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>
<CornerDownLeft stroke-width="1.5" class="h-3 w-3" />
<CornerDownLeft class="h-3 w-3" />
</Kbd>
</KbdGroup>
<Button class="w-20" size="sm" variant="outline" @click="saveNote">
<Send stroke-width="1.5" />
<Send />
</Button>
</div>
</div>
@@ -529,7 +529,7 @@ const deleteNote = async (id: number) => {
<span>{{ toLocalDate(note.createdAt) }}</span>
<div class="grow-1"></div>
<Button variant="ghost" size="sm" @click="deleteNote(note.id)">
<Trash2 stroke-width="1.5" />
<Trash2 />
</Button>
</div>
<div class="text-sm whitespace-pre-wrap">{{ note.text }}</div>
@@ -6,7 +6,7 @@ import { X } from "lucide-vue-next"
<template>
<Button size="sm">
<X stroke-width="1.5" />
<X />
</Button>
</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>
+2 -2
View File
@@ -9,8 +9,8 @@ defineProps<Props>();
<template>
<div class="mb-8 space-y-0.5">
<h2 class="text-xl font-semibold tracking-tight">{{ title }}</h2>
<p v-if="description" class="text-sm text-muted-foreground">
<h2 class="text-lg text-primary font-semibold">{{ title }}</h2>
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
</div>
+1 -2
View File
@@ -1,7 +1,6 @@
<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 { defineProps } from 'vue';
import UserInfo from '@/components/UserInfo.vue';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import UserMenuContent from '@/components/UserMenuContent.vue';
+15 -14
View File
@@ -1,11 +1,11 @@
<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 { type NavGroup } from '@/types';
import { Link, usePage } from '@inertiajs/vue3';
import { LayoutGrid, Plus } from 'lucide-vue-next';
import { LayoutGrid } from 'lucide-vue-next';
import { dashboard } from '@/routes';
import SidebarSeparator from './ui/sidebar/SidebarSeparator.vue';
import SidebarSeparator from './ui/crm-sidebar/SidebarSeparator.vue';
defineProps<{
groups: NavGroup[];
@@ -19,10 +19,10 @@ const page = usePage();
<SidebarGroup class="px-2 py-0 mt-[1.8rem]">
<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()">
<component :is="LayoutGrid" class="text-gray-500" stroke-width="1.5" />
<span>Dashboard</span>
<LayoutGrid class="text-muted-foreground" :class="urlIsActive(dashboard(), page.url) ? 'text-primary' : 'text-sidebar-icon'" />
<span>Dashboard</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -30,23 +30,24 @@ const page = usePage();
<SidebarGroup class="px-2 py-0" v-for="group in groups">
<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);" />
<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 }}
</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenu class="font-medium text-base gap-2">
<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">
<component :is="item.icon" :class="item.color" stroke-width="1.5" />
<span>{{ item.title }}</span>
<component :is="item.icon"
:class="urlIsActive(item.href, page.url) ? 'text-primary' : 'text-sidebar-icon'" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction v-if="item.action">
<Link prefetch :href="item.action.href">
<component :is="item.action.icon" :class="item.action.color" stroke-width="1.5" size="0.833rem" />
<span class="sr-only">{{ item.action.title }}</span>
<component :is="item.action.icon" class="text-sidebar-icon" size="0.833rem" />
<span class="sr-only">{{ item.action.title }}</span>
</Link>
</SidebarMenuAction>
</SidebarMenuItem>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import UserInfo from '@/components/UserInfo.vue';
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 { ChevronsUpDown } from 'lucide-vue-next';
import UserMenuContent from './UserMenuContent.vue';
+185
View File
@@ -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>
+140
View File
@@ -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>
+141
View File
@@ -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>
+2 -2
View File
@@ -22,8 +22,8 @@ const showAvatar = computed(() => props.user.avatar && props.user.avatar !== '')
<template>
<div class="flex items-center gap-2">
<Avatar class="size-8 overflow-hidden rounded-lg">
<AvatarImage v-if="showAvatar" :src="'storage/uploads/users/' + user.avatar!" :alt="user.name" />
<Avatar class="size-8">
<AvatarImage v-if="showAvatar" :src="'storage/uploads/users/' + user.avatar" :alt="user.name" />
<AvatarFallback class="rounded-full bg-primary text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
+3 -3
View File
@@ -21,7 +21,7 @@ const handleLogout = () => {
<DropdownMenuItem as-child>
<Link :href="edit()" prefetch class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Settings stroke-width="1.5" />
<Settings />
<span class="mr-4">Einstellungen</span>
</div>
<KbdGroup>
@@ -35,7 +35,7 @@ const handleLogout = () => {
<DropdownMenuItem as-child>
<Link :href="proceduralDocumentation()" prefetch class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Book stroke-width="1.5" />
<Book />
<span class="mr-4">Verfahrensdokumentation</span>
</div>
</Link>
@@ -46,7 +46,7 @@ const handleLogout = () => {
<DropdownMenuItem as-child>
<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
</Link>
</DropdownMenuItem>
@@ -4,7 +4,7 @@ import { computed } from 'vue'
import { Invoice } from '@/types'
import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils'
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<{
@@ -104,25 +104,23 @@ const calcTaxes = (amount: number) => {
<template>
<Table class="relative document-table bg-none">
<TableHeader>
<TableRow class="hover:bg-transparent border-none">
<Table class="relative document-table">
<TableHeader class="sticky top-0">
<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/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>
<TableHead class="w-1/5 text-right lg:hidden">Rechnung</TableHead>
<TableHead class="w-1/100 lg:w-1/20 lg:px-5 hidden md:table-cell">Gestellt</TableHead>
<TableHead class="w-1/5 lg:hidden">Rechnung</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 hidden lg:table-cell">Ust.</TableHead>
<TableHead class="w-1/12 text-right hidden lg:table-cell lg:pr-4">Brutto</TableHead>
</TableRow>
</TableHeader>
<TableBody class="overflow-clip rounded-lg shadow">
<TableBody>
<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)">
<TableCell class="w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5 tabular-nums">{{ invoice.nr }}
</TableCell>
@@ -131,7 +129,7 @@ const calcTaxes = (amount: number) => {
statusBadgeLabels[invoice.paymentStatus] }}
</StatusBadge>
</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) }}
</TableCell>
<TableCell class="lg:hidden max-w-[220px] md:max-w-[320px] overflow-hidden text-ellipsis">
@@ -141,7 +139,7 @@ const calcTaxes = (amount: number) => {
<TableCell
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>
<TableCell colspan="2"
<TableCell
class="hidden lg:table-cell max-w-[120px] md:max-w-[160px] lg:max-w-auto overflow-hidden text-ellipsis">
{{
invoice.title }}</TableCell>
@@ -154,10 +152,10 @@ const calcTaxes = (amount: number) => {
</TableRow>
</TableBody>
<TableFooter class="border-none bg-transparent">
<TableFooter>
<!-- Summe -->
<TableRow class="border-none hover:bg-transparent">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableRow>
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></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>
</TableRow>
<!-- Bezahlt -->
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)" class="border-none hover:bg-transparent">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableRow v-if="!(totalDue == 0 && totalNotIssued == 0)">
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
<TableCell class=" w-1/100 text-right tabular-nums font-bold">Bezahlt</TableCell>
@@ -178,11 +176,11 @@ const calcTaxes = (amount: number) => {
</TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossPaid)
}}</TableCell>
}}</TableCell>
</TableRow>
<TableRow v-if="totalDue > 0" class="border-none text-destructive hover:bg-transparent">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableRow v-if="totalDue > 0" class="text-destructive">
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Offen</TableCell>
@@ -191,10 +189,10 @@ const calcTaxes = (amount: number) => {
</TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossDue)
}}</TableCell>
}}</TableCell>
</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 md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
@@ -211,11 +209,6 @@ const calcTaxes = (amount: number) => {
</template>
<style>
.document-table {
font-size: 0.833rem;
}
.document-table th {}
.document-table td {
padding-top: 1.125em !important;
@@ -229,5 +222,5 @@ const calcTaxes = (amount: number) => {
.document-table tfoot td {
padding-top: 0.64em !important;
padding-bottom: 0.64em !important;
}
}
</style>
@@ -6,18 +6,16 @@
<!-- TODO: Steuersatz in LineItem -->
<!-- TODO: Stunden und Tagessatz aus Settings -->
<!-- TODO: Client-side validation -->
<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 { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
import axios from 'axios'
import { type DateValue, getLocalTimeZone, fromDate } from "@internationalized/date"
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded, hotkey } from '@/lib/utils'
import axios from "axios"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"
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 { Button } from '@/components/ui/crm-button'
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 { GrowingTextarea } from '../ui/growing-textarea'
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 DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue";
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
const DEBUG = ref(false)
const props = defineProps<{
invoiceData?: Invoice,
invoiceData: Invoice | undefined,
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'])
// Dialog state
const isOpen = computed({
get: () => props.modelValue,
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) {
return `Rechnung ${invoice.value.nr || ''}`
} else {
@@ -72,83 +70,99 @@ const title = computed<string>(() => {
}
})
onMounted(async () => {
// Load customers and payment terms
// Load additional data that wasnt provided by parent invoices view
try {
const promises = [];
promises.push(axios.get('/api/customers'))
promises.push(axios.get('/api/paymentterms'))
promises.push(axios.get('/api/units'))
const promises: Promise<any>[] = [];
// Create an array of promises for each data request
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)
let responseIndex = 0
customers.value = responses[responseIndex].data as Customer[]
paymentTermsData.value = responses[responseIndex + 1].data as PaymentTerms[]
units.value = responses[responseIndex + 2].data as Unit[]
// Process each response
customers.value = responses[0].data
paymentTerms.value = responses[0].data
units.value = responses[0].data
} catch (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(() => {
if (isLoading.value) isLoading.value = false
// console.group('onUpdated')
// console.error(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
})
// Initial data from parent view
watch(() => props.invoiceData, async () => {
// Reset state flag
isDirty.value = false
dataLoaded.value = false
watch(() => props.modelValue, (open) => {
// on open
if (open) {
// console.group('on open')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('on parent data')
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
}
// Reset state flags
isDirty.value = false;
isLoading.value = true;
// Get invoice data from props
invoice.value = props.invoiceData
// Get invoice data from props
// console.warn('trigger invoice watcher')
invoice.value = props.invoiceData
// Load line items
if (invoice.value && invoice.value.id !== 0) {
itemsLoading.value = true
// Load line items
if (invoice.value && invoice.value.id !== 0) {
itemsLoading.value = true
try {
itemsLoading.value = true
axios.get('/api/lineitems/' + invoice.value.id).then(response => {
if (invoice.value) invoice.value.items = response.data as LineItem[]
})
} catch (error) {
toast.error('Fehler beim Laden der Positionen', error || String(error))
}
} else {
try {
await axios.get('/api/lineitems/' + invoice.value.id).then(response => {
if (invoice.value) invoice.value.items = response.data as LineItem[]
})
} catch (error) {
toast.error('Fehler beim Laden der Positionen', error || String(error))
} finally {
await nextTick()
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(invoice,
(newValue, oldValue) => {
async (newValue, oldValue) => {
if (newValue == oldValue) return
// console.group('watch invoice')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.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) {
// console.groupEnd()
console.groupEnd()
return;
}
// Set default billing data from customer
// If no billing data is store in the invoice, generat ot from customer
if (!newValue.billingData) {
// console.warn('trigger invoice watcher')
newValue.billingData = {
companyName: newValue.customer?.companyName || "",
vatId: newValue.customer?.vatId || "",
@@ -167,8 +181,6 @@ watch(invoice,
}
if (newValue.customerId && newValue.customerId !== 0) {
// if (importCustomer.value != newValue.customer)
// console.warn('trigger importCustomer watcher')
customers.value.find(customer => {
if (customer.id === newValue.customerId) {
if (invoice.value) invoice.value.customer = customer as Customer
@@ -177,8 +189,6 @@ watch(invoice,
customer.contacts.find(contact => {
if (contact.firstName === newValue?.billingData?.contactFirstName &&
contact.lastName === newValue?.billingData?.contactLastName) {
// if (importContact.value != contact)
// console.warn('trigger importContact watcher')
importContact.value = contact
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}`)
// console.groupEnd()
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`, `dataLoaded: ${dataLoaded.value}`)
console.groupEnd()
}
},
{ deep: true }
)
@@ -204,13 +211,14 @@ watch(importCustomer,
if (newValue == oldValue) return
if (!invoice.value) return
// console.group('watch importCustomer')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('watch importCustomer')
console.log(`isDirty: ${isDirty.value}`)
}
// Don't overwrite these values during loading
// they can intentionally be different from customer data
if (!isLoading.value) {
if (dataLoaded.value) {
if (!invoice.value.billingData) invoice.value.billingData = newBillingData()
// console.warn('trigger invoice watcher')
@@ -232,8 +240,10 @@ watch(importCustomer,
isDirty.value = true;
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`)
console.groupEnd()
}
},
{ deep: true }
)
@@ -243,10 +253,12 @@ watch(importContact,
if (newValue == oldValue) return
if (!invoice.value) return
// console.group('watch importContact')
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
if (DEBUG.value) {
console.group('watch importContact')
console.log(`isDirty: ${isDirty.value}`)
}
if (!isLoading.value) {
if (dataLoaded.value) {
if (newValue.id !== 0) {
invoice.value.billingData!.contactFirstName = newValue.firstName
invoice.value.billingData!.contactLastName = newValue.lastName
@@ -258,8 +270,10 @@ watch(importContact,
isDirty.value = true;
}
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`)
// console.groupEnd()
if (DEBUG.value) {
console.log(`isDirty: ${isDirty.value}`)
console.groupEnd()
}
},
{ deep: true }
)
@@ -278,7 +292,7 @@ const save = async () => {
isSaving.value = true
try {
// Prepare the invoice data for API request
// Prepare the invoice data API request
const invoiceToSave = {
nr: invoice.value.nr,
invoiceDate: invoice.value.invoiceDate,
@@ -322,11 +336,10 @@ const save = async () => {
invoice.value = response.data;
} else {
// 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)
// isOpen.value = false
} catch (error) {
toast.error("Rechnung konnte nicht gespeichert werden", {
description: (error as Error).message,
@@ -334,7 +347,7 @@ const save = async () => {
} finally {
// remove spinner from save button
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();
}
const downloadPdf = function () {
const exportPdf = function () {
if (!invoice.value) return;
window?.open('/invoice/' + invoice.value.id + '/pdf');
}
const downloadXml = function () {
const exportXml = function () {
if (!invoice.value) return;
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[]) => {
if (isLoading.value) return;
if (!dataLoaded.value) return;
if (!invoice.value) return;
// console.group('updateLineItems');
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
console.group('updateLineItems');
console.log(`isDirty: ${isDirty.value}`);
// Konvertiere die neuen Items in normale Objekte
const rawItems = toRaw(newItems) || [];
@@ -456,13 +469,13 @@ const updateLineItems = (newItems: LineItem[]) => {
// Sortiere die Items nach 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));
// Aktualisieren Sie die Items in der Rechnung
// Update the invoice items
invoice.value.items = updatedItems;
// Berechnen Sie den neuen Gesamtbetrag
// Calculate the new total amount
let total = 0;
updatedItems.forEach(item => {
total += item.quantity * item.price;
@@ -472,11 +485,13 @@ const updateLineItems = (newItems: LineItem[]) => {
// Erzwingen Sie eine Aktualisierung der Benutzeroberfläche
invoice.value = { ...invoice.value };
// console.log(`isDirty: ${isDirty.value}\tisLoading: ${isLoading.value}`);
// console.groupEnd();
console.log(`isDirty: ${isDirty.value}`);
console.groupEnd();
}
const importLineItems = () => {
if (!isOpen) return
if (!fileInput.value) {
fileInput.value = document.createElement('input');
fileInput.value.type = 'file';
@@ -495,18 +510,14 @@ const handleFileUpload = async (event: Event) => {
formData.append('csv', file);
try {
if (!invoice.value || !invoice.value.id) {
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, {
const response = await axios.post(`/api/lineitems/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (invoice.value) {
invoice.value.items = response.data;
invoice.value.items = invoice.value.items.concat(response.data as LineItem[]);
isDirty.value = true;
}
@@ -539,7 +550,7 @@ const handleFileUpload = async (event: Event) => {
<h1>{{ title }}</h1>
</DialogTitle>
<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"
type="text" placeholder="Titel" />
</DialogDescription>
@@ -550,8 +561,8 @@ const handleFileUpload = async (event: Event) => {
<!-- Save -->
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save"
:disabled="isSaving">
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
<Check v-else stroke-width="1.5" />
<Loader2 v-if="isSaving" class="animate-spin" />
<Check v-else />
Speichern
</Button>
@@ -573,7 +584,7 @@ const handleFileUpload = async (event: Event) => {
<Tooltip v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)">
<TooltipTrigger>
<Button size="sm" variant="success" @click="updateStatus('paid')">
<FileCheck stroke-width="1.5" /> Bezahlt
<FileCheck /> Bezahlt
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -587,7 +598,6 @@ const handleFileUpload = async (event: Event) => {
<Button size="sm" variant="destructive" @click="openReminderDialog"
:disabled="reminderLoading" class="gap-0">
<Loader2 class="h-4 w-4 transition-[width] ease-in-out animate-spin"
stroke-width="1.5"
:class="{ 'w-0!': !reminderLoading, 'mr-2': reminderLoading }" />
Erinnern
</Button>
@@ -599,10 +609,10 @@ const handleFileUpload = async (event: Event) => {
<!-- Ellipsis menu -->
<DropdownMenu v-if="invoice && invoice.id > 0">
<DropdownMenu v-if="invoice">
<DropdownMenuTrigger>
<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>
</DropdownMenuTrigger>
@@ -612,7 +622,7 @@ const handleFileUpload = async (event: Event) => {
<!-- Import Items -->
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
<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">
<span>Posten importieren</span>
<span class="text-xs text-muted-foreground">(CSV)</span>
@@ -628,10 +638,9 @@ const handleFileUpload = async (event: Event) => {
<DropdownMenuSeparator />
<!-- Preview -->
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
class="flex items-center justify-between" @click="preview">
<DropdownMenuItem class="flex items-center justify-between" @click="preview">
<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>
</div>
<KbdGroup>
@@ -643,9 +652,9 @@ const handleFileUpload = async (event: Event) => {
<!-- PDF -->
<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">
<FileText stroke-width="1.5" class="text-muted-foreground" />
<FileText class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>PDF exportieren</span>
<span class="text-xs text-muted-foreground">(ZUGFeRD)</span>
@@ -660,9 +669,9 @@ const handleFileUpload = async (event: Event) => {
<!-- XML -->
<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">
<CodeXml stroke-width="1.5" class="text-muted-foreground" />
<CodeXml class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>XML exportieren</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'"
class="flex justify-between" @click="" disabled>
<div class="flex items-center gap-3">
<Logs stroke-width="1.5" class="text-muted-foreground" />
<Logs class="text-muted-foreground" />
<span>Audit</span>
</div>
</DropdownMenuItem>
@@ -688,8 +697,8 @@ const handleFileUpload = async (event: Event) => {
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
class="flex justify-between" @click="updateStatus('cancelled')">
<div class="flex items-center gap-3">
<!-- <FileX stroke-width="1.5" class="text-muted-foreground"/> -->
<Ban :strokeWidth="1.5" class="text-current" />
<!-- <FileX class="text-muted-foreground"/> -->
<Ban class="text-current" />
<span class="mr-2">Stornieren</span>
</div>
</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!"
@click="deleteInvoice">
<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>
</div>
</DropdownMenuItem>
@@ -832,7 +841,7 @@ const handleFileUpload = async (event: Event) => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<MessageCircleQuestion strokeWidth="1.5"
<MessageCircleQuestion
class="h-[16px] w-[16px] ml-[1px] mb-2" />
</TooltipTrigger>
<TooltipContent>
@@ -851,7 +860,7 @@ const handleFileUpload = async (event: Event) => {
<SelectValue placeholder="Zahlungsziel" />
</SelectTrigger>
<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-else>{{ term.days }} Tage</span>
</SelectItem>
@@ -2,20 +2,29 @@
<!-- TODO: Enter in LineItem = neue Zeile -->
<script setup lang="ts">
import { ref, watch, HTMLAttributes, onUpdated } from 'vue'
import { ref, watch, HTMLAttributes, onMounted, nextTick } from 'vue'
import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils';
import { LineItem, Unit } from '@/types';
import { newLineItem } from '@/types/index.d'
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { LineItem, Product, Unit } from '@/types';
import { newLineItem, newUnit } from '@/types/index.d'
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/crm-table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, } from '@/components/ui/number-field'
import { Input } from '@/components/ui/crm-input';
import { Loader2, GripVertical, Trash2, Plus, TextSelect } from 'lucide-vue-next';
import { Loader2, GripVertical, Trash2, Plus, TextSelect, CheckIcon, ChevronsUpDownIcon } from 'lucide-vue-next';
import { Button } from '@/components/ui/crm-button';
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 PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'
import axios, { AxiosError } from "axios";
import { toast } from "vue-sonner";
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
const DEBUG = ref(true)
const props = defineProps<{
isLoading?: boolean,
@@ -29,23 +38,73 @@ const emit = defineEmits<{
(e: 'update:lineItems', value: LineItem[]): void
}>()
const isLoading = ref(props.isLoading || false)
const items = ref((props.lineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
// const isLoading = ref(props.isLoading || false)
const items = ref(props.lineItems ?? []) // items only uses props.lineItems as the initial value;
const updateFromParent = ref(false)
onUpdated(() => {
if (isLoading.value) isLoading.value = false
})
const products = ref<Product[]>()
const productsComboboxOpen = ref(false)
/**
* Watch for data from parent view
*/
watch(() => props.lineItems, async (newLineItems) => {
// isLoading.value = (newLineItems?.length || 0) > 0
if (DEBUG.value) {
console.group('LineItemTable watch props.lineItems')
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
}
// Set flag to indicate this is an external update
updateFromParent.value = true
// Only update if the items actually changed
if (JSON.stringify(items.value) !== JSON.stringify(newLineItems)) {
items.value = (newLineItems ?? [])
} else {
console.log('already up to date, no change')
}
// items.value = (newLineItems ?? [])
// Reset flag after next tick
await nextTick()
updateFromParent.value = false
if (DEBUG.value) {
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
console.groupEnd()
}
}, { deep: true })
/**
* Watch for changes in items and emit to parent
*/
watch(items, (newItems) => {
if (isLoading.value) return
if (DEBUG.value) {
console.group('LineItemTable watch items')
console.log(`isLoading: ${props.isLoading},\tupdateFromParent: ${updateFromParent.value}`)
console.groupEnd()
}
// Don't emit changes in loading
if (props.isLoading || updateFromParent.value) return
console.log('emit update:lineItems')
emit('update:lineItems', newItems)
}, { deep: true })
watch(() => props.lineItems, (newLineItems) => {
isLoading.value = (newLineItems?.length || 0) > 0
items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })
}, { deep: true, once: true })
onMounted(async () => {
// Load products for insertion
try {
const response = await axios.get('/api/products')
products.value = response.data
} catch (error) {
toast.error('Fehler beim Laden der Produkte', { description: (error as AxiosError).message })
}
})
const newItem = (isSection: boolean) => {
let item = newLineItem(isSection)
@@ -53,6 +112,18 @@ const newItem = (isSection: boolean) => {
items.value.push(item)
}
const insertProduct = (product: Product) => {
let item = newLineItem(false)
item.title = product.title
item.description = product.description ?? ''
item.unit = product.unit ?? newUnit()
item.quantity = 1
item.price = product.price ?? 0
item.position = getNextPosition(items.value.length, false)
items.value.push(item)
}
const getNextPosition = (index: number, isSection: boolean) => {
let lastPosition = (index == 0) ? 0 : items.value.at(index - 1)?.position ?? 0
let nextInteger = (lastPosition == Math.ceil(lastPosition)) ? lastPosition + 1 : Math.ceil(lastPosition)
@@ -102,7 +173,7 @@ const recalculatePositions = () => {
</Table>
</div>
<Table :class="{ 'opacity-100!': !isLoading && items.length >= 1 }"
<Table :class="{ 'opacity-100!': !props.isLoading && items.length >= 1 }"
class="table-fixed transition-opacity opacity-0 duration-300">
<!-- Dummy header so a col spanning title section in first row
@@ -124,12 +195,12 @@ const recalculatePositions = () => {
<TableRow v-if="element.isSection">
<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>
<!-- Title -->
<TableCell colspan="6" class="pt-6">
<Input v-model="element.title" placeholder="Titel"
<Input v-model="element.title" placeholder="Titel" id=""
class="font-bold text-base! p-1 h-7! m-0 -mb-1 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none hover:border-1 dark:hover:border-1 placeholder:text-muted-foreground/30 shadow-none" />
<GrowingTextarea v-model="element.description" placeholder="Text"
class="font-light bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" />
@@ -140,7 +211,7 @@ const recalculatePositions = () => {
<TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" stroke-width="1.5" />
<Trash2 :size="18" />
</Button>
</TableCell>
</TableRow>
@@ -148,7 +219,7 @@ const recalculatePositions = () => {
<TableRow v-else>
<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>
<!-- Pos. -->
@@ -202,19 +273,19 @@ const recalculatePositions = () => {
<!-- Total -->
<TableCell class="w-1/8 text-right tabular-nums font-bold">{{ toCurrency(element.price * element.quantity)
}}
}}
</TableCell>
<!-- Buttons -->
<TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 stroke-width="1.5" />
<Trash2 />
</Button>
<!-- <Button variant="ghost" size="sm" @click=""
class="has-[>svg]:px-1 text-muted-foreground">
<BetweenHorizonalEnd stroke-width="1.5"/>
<BetweenHorizonalEnd />
</Button> -->
</TableCell>
@@ -227,12 +298,49 @@ const recalculatePositions = () => {
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8">
<div class="flex gap-2 justify-center">
<Button class="mt-4" variant="ghost" @click="newItem(true)">
<Plus /> Neuer Abschnitt
<Plus /> Neuer Abschnitt
</Button>
<Button class="mt-4" variant="ghost" @click="newItem(false)">
<Plus /> Neue Zeile
<Plus /> Neue Zeile
</Button>
<Popover v-model:open="productsComboboxOpen">
<PopoverTrigger as-child>
<Button variant="ghost" role="combobox" :aria-expanded="productsComboboxOpen" class="mt-4"
:disabled="!products || products.length === 0">
<Plus />
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 -->
<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"
class="size-full object-cover dark:brightness-75" loading="lazy" />
<PlaceholderPattern v-else />
</div>
{{ product.title }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</TableCell>
</TableRow>
@@ -240,23 +348,66 @@ const recalculatePositions = () => {
</Table>
<Loader2 v-if="isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground" stroke-width="1.5" />
<Loader2 v-if="props.isLoading" class="mx-auto mt-8 h-6 w-6 animate-spin text-muted-foreground"
/>
<Empty v-else-if="items.length < 1" class="py-8!">
<EmptyHeader>
<EmptyMedia variant="icon">
<TextSelect class="text-muted-foreground" stroke-width="1.5" />
<TextSelect class="text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
</EmptyHeader>
<div class="flex gap-2">
<Button variant="action" @click="newItem(true)">
<Plus stroke-width="1.5" /> Neuer Abschnitt
</Button>
<Button variant="action" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile
</Button>
<ButtonGroup>
<Button @click="newItem(true)">
<Plus /> Neuer Abschnitt
</Button>
<Button @click="newItem(false)">
<Plus /> Neue Zeile
</Button>
<Popover v-model:open="productsComboboxOpen">
<PopoverTrigger as-child>
<Button role="combobox" :aria-expanded="productsComboboxOpen"
:disabled="!products || products.length === 0">
<Plus />
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 -->
<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"
class="size-full object-cover dark:brightness-75" loading="lazy" />
<PlaceholderPattern v-else />
</div>
{{ product.title }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</ButtonGroup>
</div>
</Empty>
@@ -1,36 +1,29 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
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 props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
data-slot="checkbox"
v-bind="forwarded"
: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)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="flex items-center justify-center text-current transition-none"
>
<CheckboxIndicator class="grid place-content-center text-current">
<slot>
<Check class="size-3.5" />
<Check class="h-4 w-4" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
+1 -1
View File
@@ -1 +1 @@
export { default as Checkbox } from './Checkbox.vue'
export { default as Checkbox } from "./Checkbox.vue"
@@ -0,0 +1,86 @@
<script setup lang="ts">
import type { ListboxRootEmits, ListboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui"
import { reactive, ref, watch } from "vue"
import { cn } from "@/lib/utils"
import { provideCommandContext } from "."
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes["class"] }>(), {
modelValue: "",
})
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: "base" })
const filterState = reactive({
search: "",
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map() as Map<string, number>,
/** Set of groups with at least one visible item. */
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
// Do nothing, each item will know to show itself because search is empty
return
}
// Reset the groups
filterState.filtered.groups = new Set()
let itemCount = 0
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score)
itemCount++
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(() => filterState.search, () => {
filterItems()
})
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
v-bind="forwarded"
:class="cn('flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', props.class)"
>
<slot />
</ListboxRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { useForwardPropsEmits } from "reka-ui"
import { Dialog, DialogContent } from '@/components/ui/dialog'
import Command from "./Command.vue"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-bind="forwarded">
<DialogContent class="overflow-hidden p-0 shadow-lg">
<Command class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<slot />
</Command>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { computed } from "vue"
import { cn } from "@/lib/utils"
import { useCommand } from "."
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
)
</script>
<template>
<Primitive v-if="isRender" v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</Primitive>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { ListboxGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui"
import { computed, onMounted, onUnmounted } from "vue"
import { cn } from "@/lib/utils"
import { provideCommandGroupContext, useCommand } from "."
const props = defineProps<ListboxGroupProps & {
class?: HTMLAttributes["class"]
heading?: string
}>()
const delegatedProps = reactiveOmit(props, "class")
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id))
allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ListboxFilterProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Search } from "lucide-vue-next"
import { ListboxFilter, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { useCommand } from "."
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ListboxFilterProps & {
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
auto-focus
:class="cn('flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { ListboxItemEmits, ListboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit, useCurrentElement } from "@vueuse/core"
import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui"
import { computed, onMounted, onUnmounted, ref } from "vue"
import { cn } from "@/lib/utils"
import { useCommand, useCommandGroup } from "."
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
}
else {
const filteredCurrentItem = filterState.filtered.items.get(id)
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true
}
// Check with filter
return filteredCurrentItem > 0
}
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement))
return
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? props?.value!.toString())
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
}
else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
:class="cn('relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', props.class)"
@select="() => {
filterState.search = ''
}"
>
<slot />
</ListboxItem>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ListboxContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ListboxContent, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ListboxContent v-bind="forwarded" :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)">
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
v-bind="delegatedProps"
:class="cn('-mx-1 h-px bg-border', props.class)"
>
<slot />
</Separator>
</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>
<span :class="cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class)">
<slot />
</span>
</template>
@@ -0,0 +1,25 @@
import type { Ref } from "vue"
import { createContext } from "reka-ui"
export { default as Command } from "./Command.vue"
export { default as CommandDialog } from "./CommandDialog.vue"
export { default as CommandEmpty } from "./CommandEmpty.vue"
export { default as CommandGroup } from "./CommandGroup.vue"
export { default as CommandInput } from "./CommandInput.vue"
export { default as CommandItem } from "./CommandItem.vue"
export { default as CommandList } from "./CommandList.vue"
export { default as CommandSeparator } from "./CommandSeparator.vue"
export { default as CommandShortcut } from "./CommandShortcut.vue"
export const [useCommand, provideCommandContext] = createContext<{
allItems: Ref<Map<string, string>>
allGroups: Ref<Map<string, Set<string>>>
filterState: {
search: string
filtered: { count: number, items: Map<string, number>, groups: Set<string> }
}
}>("Command")
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
id?: string
}>("CommandGroup")
@@ -13,7 +13,9 @@ export const badgeVariants = cva(
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
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:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
+13 -12
View File
@@ -3,30 +3,31 @@ import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from './Button.vue'
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: {
variant: {
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:
'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:
'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:
'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:
'border-none shadow-none hover:bg-slate-100 hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
'border-none shadow-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
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: {
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',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
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"
+6 -10
View File
@@ -20,14 +20,10 @@ const modelValue = useVModel(props, 'modelValue', emits, {
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'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',
'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,
)"
>
<input v-model="modelValue" data-slot="input" :class="cn(
'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',
'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>
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More