Add Notes model, controller and database table. Implement it in Customer Dialog, #6
This commit is contained in:
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
|
use App\Models\Note;
|
||||||
use App\Support\ApiDataTransformer;
|
use App\Support\ApiDataTransformer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class CustomerController extends Controller
|
class CustomerController extends Controller
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Note;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Support\ApiDataTransformer;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class NoteController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, $modelId)
|
||||||
|
{
|
||||||
|
$modelType = $request->route()->parameter('modelType');
|
||||||
|
if (!$modelType) {
|
||||||
|
$modelType = $request->route()->getAction('modelType');
|
||||||
|
}
|
||||||
|
$model = app("App\\Models\\" . ucfirst($modelType))::findOrFail($modelId);
|
||||||
|
|
||||||
|
// Lade alle Notizen des Modells mit der Benutzerbeziehung
|
||||||
|
$notes = $model->notes()->with('user')->orderBy('created_at', 'desc')->get();
|
||||||
|
|
||||||
|
// Transformiere die Daten in camelCase
|
||||||
|
$notesArray = $notes->map(function ($note) {
|
||||||
|
return ApiDataTransformer::snakeToCamel($note->toArray());
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($notesArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, $modelId)
|
||||||
|
{
|
||||||
|
$modelType = $request->route()->parameter('modelType');
|
||||||
|
if (!$modelType) {
|
||||||
|
$modelType = $request->route()->getAction('modelType');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'text' => 'required|string',
|
||||||
|
'userId' => 'required|integer'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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->notes()->save($note);
|
||||||
|
|
||||||
|
return response()->json($this->single($note->id), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function single($id)
|
||||||
|
{
|
||||||
|
$note = Note::with('user')->findOrFail($id);
|
||||||
|
return ApiDataTransformer::snakeToCamel($note->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Note $note)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function delete(int $id)
|
||||||
|
{
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$note = note::findOrFail($id);
|
||||||
|
$note->delete();
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Notiz gelöscht'
|
||||||
|
], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Notiz konnte nicht gelöscht werden',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ class Customer extends Model
|
|||||||
'billing_address',
|
'billing_address',
|
||||||
'payment_terms_id',
|
'payment_terms_id',
|
||||||
'status',
|
'status',
|
||||||
'notes',
|
|
||||||
'logo',
|
'logo',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -96,4 +95,12 @@ public function paymentTerms()
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(PaymentTerms::class);
|
return $this->belongsTo(PaymentTerms::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notes for the customer
|
||||||
|
*/
|
||||||
|
public function notes()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Note::class, 'notable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Note extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\NoteFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'text',
|
||||||
|
'notable_id',
|
||||||
|
'notable_type'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notable()
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Note>
|
||||||
|
*/
|
||||||
|
class NoteFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'text' => $this->faker->sentences(2, true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,6 @@ public function up()
|
|||||||
$table->json('billing_address')->nullable();
|
$table->json('billing_address')->nullable();
|
||||||
$table->foreignId('payment_terms_id')->constrained()->default(3); // Standard-Zahlungsziel: 14 Tage
|
$table->foreignId('payment_terms_id')->constrained()->default(3); // Standard-Zahlungsziel: 14 Tage
|
||||||
$table->enum('status', ['active', 'inactive', 'prospect'])->default('active');
|
$table->enum('status', ['active', 'inactive', 'prospect'])->default('active');
|
||||||
$table->text('notes')->nullable();
|
|
||||||
$table->string('logo')->nullable();
|
$table->string('logo')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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('notes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->nullOnDelete();
|
||||||
|
$table->text('text');
|
||||||
|
|
||||||
|
// Polymorphische Beziehung
|
||||||
|
$table->unsignedBigInteger('notable_id');
|
||||||
|
$table->string('notable_type');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'notable_id', 'notable_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
|
use App\Models\Note;
|
||||||
use App\Models\Invoice;
|
use App\Models\Invoice;
|
||||||
use App\Models\LineItem;
|
use App\Models\LineItem;
|
||||||
|
|
||||||
@@ -21,14 +22,14 @@ public function run(): void
|
|||||||
SettingsTableSeeder::class
|
SettingsTableSeeder::class
|
||||||
]);
|
]);
|
||||||
|
|
||||||
User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'name' => 'Daniel Stock',
|
'name' => 'Daniel Stock',
|
||||||
'email' => 'daniel.stock@tooloop.de',
|
'email' => 'daniel.stock@tooloop.de',
|
||||||
'password' => bcrypt('6T0az2JGO5oA'),
|
'password' => bcrypt('6T0az2JGO5oA'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create customers with contacts
|
// Create customers with contacts
|
||||||
Customer::factory(20)->create()->each(function ($customer) {
|
Customer::factory(20)->create()->each(function ($customer) use ($user) {
|
||||||
// Create a primary contact for each customer
|
// Create a primary contact for each customer
|
||||||
Contact::factory()->create([
|
Contact::factory()->create([
|
||||||
'customer_id' => $customer->id,
|
'customer_id' => $customer->id,
|
||||||
@@ -39,6 +40,13 @@ public function run(): void
|
|||||||
Contact::factory(rand(1, 3))->create([
|
Contact::factory(rand(1, 3))->create([
|
||||||
'customer_id' => $customer->id,
|
'customer_id' => $customer->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
// Create some notes for each customer
|
||||||
|
Note::factory(rand(0, 5))->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create invoices
|
// Create invoices
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue"
|
import { ref, computed, watch, useTemplateRef } from "vue"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogClose } from "@/components/ui/dialog"
|
||||||
import { Button } from '@/components/ui/crm-button'
|
import { Button } from '@/components/ui/crm-button'
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/crm-textarea";
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/crm-input';
|
||||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea } from '@/components/ui/crm-input-group'
|
import { Customer, Note } from '@/types'
|
||||||
import { Customer } from '@/types'
|
|
||||||
import { toast } from "vue-sonner"
|
import { toast } from "vue-sonner"
|
||||||
import { alertStore } from "@/stores/alertStore"
|
import { alertStore } from "@/stores/alertStore"
|
||||||
import { Trash2, Loader2, Ellipsis, Check, ImportIcon, Mail, Notebook, Send, CornerDownLeft, Plus, CirclePlus, Phone, PhoneCall } from "lucide-vue-next"
|
import { Trash2, Loader2, Ellipsis, Check, Mail, Send, CornerDownLeft, Plus, PhoneCall } from "lucide-vue-next"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
||||||
import DialogCloseButton from "./DialogCloseButton/DialogCloseButton.vue";
|
import DialogCloseButton from "./DialogCloseButton/DialogCloseButton.vue";
|
||||||
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { bgColorForString, toLocalDate, randomDate, loremIpsum } from '@/lib/utils'
|
import { bgColorForString, toLocalDate } from '@/lib/utils'
|
||||||
import { getInitials } from '@/composables/useInitials';
|
import { getInitials } from '@/composables/useInitials';
|
||||||
import GrowingTextarea from "./ui/growing-textarea/GrowingTextarea.vue";
|
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
import Checkbox from "./ui/checkbox/Checkbox.vue";
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
customerData: Customer | null,
|
customerData: Customer | null,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'update:open', 'save', 'cancel', 'delete'])
|
const emit = defineEmits(['update:modelValue', 'update:open', 'save', 'cancel', 'delete'])
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const auth = computed(() => page.props.auth);
|
||||||
const customer = ref<Customer | null>(props.customerData)
|
const customer = ref<Customer | null>(props.customerData)
|
||||||
const isDirty = ref(false);
|
const isDirty = ref(false)
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false)
|
||||||
|
const isTakingNote = ref(false)
|
||||||
|
const notesLoading = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const alert = alertStore()
|
const alert = alertStore()
|
||||||
const isTakingNote = ref(false)
|
const noteText = ref("")
|
||||||
|
const noteTextArea = useTemplateRef('note-textarea')
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -43,6 +46,12 @@ const isOpen = computed({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const todos = ref([
|
||||||
|
{ text: 'Lorem ipsum', dueDate: '2025-11-25', done: false, type: 'phoneCall' },
|
||||||
|
{ text: 'Lorem ipsum', dueDate: '2025-11-25', done: false, type: 'mail' },
|
||||||
|
{ text: 'Lorem ipsum', dueDate: '2025-11-25', done: false, type: 'task' }
|
||||||
|
])
|
||||||
|
|
||||||
// watch for new external invoice data
|
// watch for new external invoice data
|
||||||
watch(() => props.modelValue, (open) => {
|
watch(() => props.modelValue, (open) => {
|
||||||
// on open
|
// on open
|
||||||
@@ -53,6 +62,24 @@ watch(() => props.modelValue, (open) => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
customer.value = props.customerData
|
customer.value = props.customerData
|
||||||
|
|
||||||
|
// load notes
|
||||||
|
if (customer.value && customer.value.id !== 0) {
|
||||||
|
notesLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
notesLoading.value = true
|
||||||
|
axios.get('/api/customers/' + customer.value.id + '/notes/').then(response => {
|
||||||
|
if (customer.value) {
|
||||||
|
customer.value.notes = response.data as Note<Customer>[]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Laden der Notizen', error || String(error))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notesLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,6 +87,14 @@ watch(customer, (newValue) => {
|
|||||||
// isDirty.value = true
|
// isDirty.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(isTakingNote, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
if (noteTextArea.value && noteTextArea.value.textareaRef) {
|
||||||
|
noteTextArea.value.textareaRef.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (customer.value) {
|
if (customer.value) {
|
||||||
// add spinner to save button
|
// add spinner to save button
|
||||||
@@ -126,7 +161,65 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const saveNote = async () => {
|
||||||
|
if (!customer.value) return
|
||||||
|
if (!noteText.value.trim()) {
|
||||||
|
isTakingNote.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/customers/${customer.value.id}/notes`, {
|
||||||
|
text: noteText.value,
|
||||||
|
userId: auth.value.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Füge die neue Notiz zum Kunden hinzu
|
||||||
|
if (customer.value.notes) {
|
||||||
|
customer.value.notes.unshift(response.data);
|
||||||
|
} else {
|
||||||
|
customer.value.notes = [response.data];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leere das Notiz-Feld und schließe das Notiz-Formular
|
||||||
|
noteText.value = ""
|
||||||
|
isTakingNote.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Speichern der Notiz:", error);
|
||||||
|
toast.error("Fehler beim Speichern der Notiz", {
|
||||||
|
description: (error as AxiosError).response?.data?.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 () => {
|
||||||
|
try {
|
||||||
|
if (!customer.value?.notes) return
|
||||||
|
|
||||||
|
await axios.delete('/api/notes/' + id)
|
||||||
|
const index = customer.value.notes.findIndex(note => note.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
customer.value.notes.splice(index, 1)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Löschen der Notiz:", error);
|
||||||
|
toast.error("Fehler beim Löschen der Notiz", {
|
||||||
|
description: (error as AxiosError).response?.data?.message || String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -361,7 +454,7 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="flex flex-col">
|
<ul class="flex flex-col">
|
||||||
<li v-for="i in 3">
|
<li v-for="todo in todos">
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-[calc(var(--spacing)_*_6)_auto] gap-y-0 gap-x-2 items-start border-b-1 border-foreground/20 py-2.5 pl-1 pr-2">
|
class="grid grid-cols-[calc(var(--spacing)_*_6)_auto] gap-y-0 gap-x-2 items-start border-b-1 border-foreground/20 py-2.5 pl-1 pr-2">
|
||||||
<div
|
<div
|
||||||
@@ -373,14 +466,17 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Input :value="loremIpsum(Math.random() * 5 + 1)"
|
<Input v-model="todo.text"
|
||||||
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
|
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
|
||||||
<!-- <PhoneCall stroke-width="1.5" :size="16" class="text-muted-foreground" /> -->
|
<PhoneCall v-if="todo.type === 'phoneCall'" stroke-width="1.5" :size="16"
|
||||||
<!-- <Check stroke-width="1.5" :size="16" class="text-muted-foreground" /> -->
|
class="text-muted-foreground" />
|
||||||
<Mail stroke-width="1.5" :size="16" class="text-muted-foreground" />
|
<Check v-else-if="todo.type === 'task'" stroke-width="1.5" :size="16"
|
||||||
|
class="text-muted-foreground" />
|
||||||
|
<Mail v-else-if="todo.type === 'mail'" stroke-width="1.5" :size="16"
|
||||||
|
class="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-muted-foreground">{{ toLocalDate(randomDate()) }}</div>
|
<div class="text-sm text-muted-foreground">{{ toLocalDate(todo.dueDate) }}</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -393,7 +489,7 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="font-bold">Notizen</h2>
|
<h2 class="font-bold">Notizen</h2>
|
||||||
<Button variant="ghost" @click="isTakingNote = !isTakingNote">
|
<Button variant="ghost" @click="isTakingNote = true">
|
||||||
<Plus stroke-width="1.5" />
|
<Plus stroke-width="1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,8 +497,8 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
|
|
||||||
<div :class="{ 'h-0': !isTakingNote }"
|
<div :class="{ 'h-0': !isTakingNote }"
|
||||||
class="my-6 flex flex-col items-end gap-2 overflow-hidden transition-[height] h-[calc-size(auto)]">
|
class="my-6 flex flex-col items-end gap-2 overflow-hidden transition-[height] h-[calc-size(auto)]">
|
||||||
<Textarea v-if="customer" v-model="customer.notes" class="bg-background resize-none"
|
<Textarea ref="note-textarea" v-if="customer" v-model="noteText"
|
||||||
placeholder="Neue Notiz"></Textarea>
|
class="bg-background resize-none" placeholder="Neue Notiz"></Textarea>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<KbdGroup class="ml-2">
|
<KbdGroup class="ml-2">
|
||||||
@@ -412,7 +508,7 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
<CornerDownLeft stroke-width="1.5" class="h-3 w-3" />
|
<CornerDownLeft stroke-width="1.5" class="h-3 w-3" />
|
||||||
</Kbd>
|
</Kbd>
|
||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
<Button class="w-20" size="sm" variant="outline">
|
<Button class="w-20" size="sm" variant="outline" @click="saveNote">
|
||||||
<Send stroke-width="1.5" />
|
<Send stroke-width="1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,19 +518,21 @@ const handleLogoUpload = (event: Event) => {
|
|||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div class="overflow-y-auto flex flex-col gap-6">
|
<div class="overflow-y-auto flex flex-col gap-6">
|
||||||
<article v-for="i in 5">
|
<article v-for="note in customer?.notes">
|
||||||
<div class="text-muted-foreground text-sm font-medium flex gap-3 items-center mb-1">
|
<div class="text-muted-foreground text-sm font-medium flex gap-3 items-center mb-1">
|
||||||
<Avatar class="size-6 text-xs">
|
<Avatar class="size-6 text-xs">
|
||||||
<AvatarImage :src="'https://i.pravatar.cc/100?img=' + i" loading="lazy" />
|
<AvatarImage :src="(note.user.avatar || '')" loading="lazy" />
|
||||||
<AvatarFallback :class="bgColorForString(getInitials('Daniel Stock'))">
|
<AvatarFallback :class="bgColorForString(getInitials(note.user.name))">
|
||||||
{{ getInitials('Daniel Stock') }}
|
{{ getInitials(note.user.name) }}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span>{{ toLocalDate(randomDate()) }}</span>
|
<span>{{ toLocalDate(note.createdAt) }}</span>
|
||||||
</div>
|
<div class="grow-1"></div>
|
||||||
<div class="text-sm">
|
<Button variant="ghost" size="sm" @click="deleteNote(note.id)">
|
||||||
{{ loremIpsum(Math.random() * 30) }}
|
<Trash2 stroke-width="1.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-sm whitespace-pre-wrap">{{ note.text }}</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Vendored
+28
-1
@@ -104,6 +104,32 @@ export function newContact(): Contact {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Notable {
|
||||||
|
id: number;
|
||||||
|
notes?: Note[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note<T extends Notable = Notable> {
|
||||||
|
id: number;
|
||||||
|
user: User;
|
||||||
|
text: string;
|
||||||
|
notable_id: number;
|
||||||
|
notable_type: string;
|
||||||
|
notable?: T;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newNote<T extends Notable>(user: User, notable: T): Note<T> {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
user: user,
|
||||||
|
text: '',
|
||||||
|
notable_id: notable.id,
|
||||||
|
notable_type: notable.constructor.name,
|
||||||
|
notable: notable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface Customer {
|
export interface Customer {
|
||||||
id: number;
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -118,7 +144,7 @@ export interface Customer {
|
|||||||
paymentTerms: PaymentTerms;
|
paymentTerms: PaymentTerms;
|
||||||
status: string;
|
status: string;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
notes?: string;
|
notes?: Note<Customer>[];
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +160,7 @@ export function newCustomer(): Customer {
|
|||||||
billingAddress: newAddress(),
|
billingAddress: newAddress(),
|
||||||
paymentTerms: newPaymentTerms(),
|
paymentTerms: newPaymentTerms(),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
notes: [],
|
||||||
logo: null
|
logo: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-5
@@ -2,20 +2,27 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\CustomerController;
|
use App\Http\Controllers\CustomerController;
|
||||||
|
use App\Http\Controllers\NoteController;
|
||||||
use App\Http\Controllers\InvoiceController;
|
use App\Http\Controllers\InvoiceController;
|
||||||
use App\Http\Controllers\LineItemController;
|
use App\Http\Controllers\LineItemController;
|
||||||
use App\Http\Controllers\PaymentTermsController;
|
use App\Http\Controllers\PaymentTermsController;
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
use App\Mail\OrderConfirmation;
|
use App\Mail\OrderConfirmation;
|
||||||
|
|
||||||
|
|
||||||
Route::get('/customers/{id}', [CustomerController::class, 'single']);
|
Route::get('/customers/{id}', [CustomerController::class, 'single']);
|
||||||
Route::get('/customers', [CustomerController::class, 'index']);
|
Route::get('/customers', [CustomerController::class, 'index']);
|
||||||
// ->middleware('auth:sanctum');
|
|
||||||
|
Route::get('/customers/{id}/notes', [NoteController::class, 'index'])
|
||||||
|
->defaults('modelType', 'customer')
|
||||||
|
->name('customers.notes.index');
|
||||||
|
|
||||||
|
Route::post('/customers/{id}/notes', [NoteController::class, 'store'])
|
||||||
|
->defaults('modelType', 'customer')
|
||||||
|
->name('customers.notes.store');
|
||||||
|
|
||||||
|
|
||||||
// Route::apiResource('invoices', InvoiceController::class);
|
Route::delete('/notes/{id}', [NoteController::class, 'delete']);
|
||||||
// ->middleware('auth:sanctum');
|
|
||||||
Route::get('/invoices/summary', [InvoiceController::class, 'summaryAll']);
|
Route::get('/invoices/summary', [InvoiceController::class, 'summaryAll']);
|
||||||
Route::get('/invoices/summaryThisYear', [InvoiceController::class, 'summaryThisYear']);
|
Route::get('/invoices/summaryThisYear', [InvoiceController::class, 'summaryThisYear']);
|
||||||
Route::get('/invoices/summaryBeforeThisYear', [InvoiceController::class, 'summaryBeforeThisYear']);
|
Route::get('/invoices/summaryBeforeThisYear', [InvoiceController::class, 'summaryBeforeThisYear']);
|
||||||
@@ -26,7 +33,6 @@
|
|||||||
Route::delete('/invoices/{id}', [InvoiceController::class, 'delete']);
|
Route::delete('/invoices/{id}', [InvoiceController::class, 'delete']);
|
||||||
Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']);
|
Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']);
|
||||||
|
|
||||||
|
|
||||||
Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']);
|
Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user