Compare commits
24 Commits
a2be833d46
...
f85d4f20bb
| Author | SHA1 | Date |
|---|---|---|
|
|
f85d4f20bb | |
|
|
0dc3160678 | |
|
|
7ec1a3529e | |
|
|
125f82e382 | |
|
|
b3e44efd13 | |
|
|
11d9ba502d | |
|
|
d5de439bb6 | |
|
|
7f3c7aeca1 | |
|
|
26d2d87104 | |
|
|
17de29ec91 | |
|
|
a1c7ee43f7 | |
|
|
304238ec98 | |
|
|
04729c071e | |
|
|
53b6c1d326 | |
|
|
2d4ef3d6e1 | |
|
|
63e32ded8a | |
|
|
f066abd0d7 | |
|
|
428e5ba3a0 | |
|
|
bfb943af62 | |
|
|
9b80414ff1 | |
|
|
a44f3e77df | |
|
|
b1b46190fc | |
|
|
4fd2d11137 | |
|
|
6bcd8dcb5f |
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Casts;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PhoneNumberCast implements CastsAttributes
|
||||||
|
{
|
||||||
|
public function get(Model $model, string $key, $value, array $attributes): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return match (strlen($value)) {
|
||||||
|
7 => substr($value, 0, 3).'-'.substr($value, 3),
|
||||||
|
10 => '('.substr($value, 0, 3).') '.substr($value, 3, 3).'-'.substr($value, 6),
|
||||||
|
default => strlen($value) > 10
|
||||||
|
? '+'.substr($value, 0, -10).' ('.substr($value, -10, 3).') '.substr($value, -7, 3).'-'.substr($value,
|
||||||
|
-4)
|
||||||
|
: $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(Model $model, string $key, $value, array $attributes)
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_replace('/\D/', '', $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,4 +6,12 @@ enum ClientStatus: string
|
||||||
{
|
{
|
||||||
case ACTIVE = 'active';
|
case ACTIVE = 'active';
|
||||||
case INACTIVE = 'inactive';
|
case INACTIVE = 'inactive';
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::ACTIVE => 'green',
|
||||||
|
self::INACTIVE => 'zinc',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,24 @@ enum InvoiceStatus: string
|
||||||
case POSTED = 'posted';
|
case POSTED = 'posted';
|
||||||
case VOID = 'void';
|
case VOID = 'void';
|
||||||
case PAID = 'paid';
|
case PAID = 'paid';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRAFT => 'Draft',
|
||||||
|
self::POSTED => 'Posted',
|
||||||
|
self::VOID => 'Voided',
|
||||||
|
self::PAID => 'Paid',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function color(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DRAFT => 'gray',
|
||||||
|
self::POSTED => 'green',
|
||||||
|
self::VOID => 'zinc',
|
||||||
|
self::PAID => 'blue',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
class ClientController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('clients.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\ClientStatus;
|
use App\Enums\ClientStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
@ -30,11 +31,9 @@ class Client extends Model
|
||||||
->withPivot('is_primary');
|
->withPivot('is_primary');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function primaryContact(): BelongsToMany
|
protected function primaryContact(): Attribute
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Contact::class)
|
return Attribute::get(fn () => $this->contacts()->wherePivot('is_primary', true)->first());
|
||||||
->wherePivot('is_primary', true)
|
|
||||||
->withPivot('is_primary');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function invoices(): HasMany
|
public function invoices(): HasMany
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Casts\PhoneNumberCast;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
@ -13,6 +15,10 @@ class Contact extends Model
|
||||||
|
|
||||||
public $fillable = ['first_name', 'last_name', 'email', 'phone'];
|
public $fillable = ['first_name', 'last_name', 'email', 'phone'];
|
||||||
|
|
||||||
|
public $casts = [
|
||||||
|
'phone' => PhoneNumberCast::class,
|
||||||
|
];
|
||||||
|
|
||||||
public function clients(): BelongsToMany
|
public function clients(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Client::class);
|
return $this->belongsToMany(Client::class);
|
||||||
|
|
@ -22,4 +28,11 @@ class Contact extends Model
|
||||||
{
|
{
|
||||||
return Invoice::whereIn('client_id', $this->clients()->pluck('clients.id'));
|
return Invoice::whereIn('client_id', $this->clients()->pluck('clients.id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function fullName(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn (mixed $value, array $attributes) => $attributes['first_name'].' '.$attributes['last_name'],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,32 @@ namespace App\Models;
|
||||||
|
|
||||||
use App\Casts\MoneyCast;
|
use App\Casts\MoneyCast;
|
||||||
use App\Enums\InvoiceStatus;
|
use App\Enums\InvoiceStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Invoice extends Model
|
class Invoice extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
public static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Invoice $invoice) {
|
||||||
|
$invoice->invoice_number ??= static::generateInvoiceNumber();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateInvoiceNumber(): string
|
||||||
|
{
|
||||||
|
$prefix = date('y').'-';
|
||||||
|
|
||||||
|
do {
|
||||||
|
$number = $prefix.str_pad(random_int(0, 99999), 5, '0', STR_PAD_LEFT);
|
||||||
|
} while (static::where('invoice_number', $number)->exists());
|
||||||
|
|
||||||
|
return $number;
|
||||||
|
}
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'invoice_number',
|
'invoice_number',
|
||||||
'client_id',
|
'client_id',
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
@ -24,6 +25,10 @@ class AppServiceProvider extends ServiceProvider
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->configureDefaults();
|
$this->configureDefaults();
|
||||||
|
|
||||||
|
Carbon::macro('local', function () {
|
||||||
|
return $this->tz(config('app.display_timezone', 'UTC'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configureDefaults(): void
|
protected function configureDefaults(): void
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (! function_exists('formatMoney')) {
|
||||||
|
function formatMoney(int|float $dollars): string
|
||||||
|
{
|
||||||
|
return '$'.number_format($dollars, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,24 +9,28 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.49",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.11.0",
|
||||||
"livewire/flux": "^2.9.0",
|
"livewire/flux": "^2.11.1",
|
||||||
"livewire/livewire": "^4.0"
|
"livewire/livewire": "^4.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.24.1",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.4",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.27",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.52",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6.12",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.8.3",
|
||||||
"pestphp/pest": "^3.8",
|
"pestphp/pest": "^4.0",
|
||||||
"pestphp/pest-plugin-laravel": "^3.2"
|
"pestphp/pest-plugin-browser": "^4.0",
|
||||||
|
"pestphp/pest-plugin-laravel": "^4.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"app/helpers.php"
|
||||||
|
],
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
|
@ -67,8 +71,7 @@
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
],
|
],
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
"@php artisan boost:update --ansi"
|
|
||||||
],
|
],
|
||||||
"post-root-package-install": [
|
"post-root-package-install": [
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -66,6 +66,8 @@ return [
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
'timezone' => 'UTC',
|
||||||
|
'display_timezone' => env('APP_DISPLAY_TIMEZONE', 'UTC'),
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ class ClientFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $this->faker->name(),
|
'name' => $this->faker->company(),
|
||||||
'abbreviation' => $this->faker->word(),
|
'abbreviation' => $this->faker->word(),
|
||||||
'audition_date' => $this->faker->dateTimeBetween('+5 days', '+1 year'),
|
'audition_date' => $this->faker->dateTimeBetween('+5 days', '+1 year'),
|
||||||
'status' => ClientStatus::ACTIVE,
|
'status' => ClientStatus::ACTIVE,
|
||||||
|
|
@ -27,7 +27,10 @@ class ClientFactory extends Factory
|
||||||
public function withContact(?Contact $contact = null): static
|
public function withContact(?Contact $contact = null): static
|
||||||
{
|
{
|
||||||
return $this->afterCreating(function (Client $client) use ($contact) {
|
return $this->afterCreating(function (Client $client) use ($contact) {
|
||||||
$client->contacts()->attach($contact ?? Contact::factory()->create());
|
$client->contacts()->attach(
|
||||||
|
$contact ?? Contact::factory()->create(),
|
||||||
|
['is_primary' => true]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ class ContactFactory extends Factory
|
||||||
public function withClient(?Client $client = null): static
|
public function withClient(?Client $client = null): static
|
||||||
{
|
{
|
||||||
return $this->afterCreating(function (Contact $contact) use ($client) {
|
return $this->afterCreating(function (Contact $contact) use ($client) {
|
||||||
$contact->clients()->attach($client ?? Client::factory()->create());
|
$contact->clients()->attach(
|
||||||
|
$client ?? Client::factory()->create(),
|
||||||
|
['is_primary' => true]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\InvoiceStatus;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class InvoiceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Invoice::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => InvoiceStatus::DRAFT,
|
||||||
|
'invoice_date' => Carbon::now(),
|
||||||
|
'due_date' => Carbon::now()->addDays(30),
|
||||||
|
'notes' => $this->faker->word(),
|
||||||
|
'internal_notes' => $this->faker->word(),
|
||||||
|
'created_at' => Carbon::now(),
|
||||||
|
'updated_at' => Carbon::now(),
|
||||||
|
|
||||||
|
'client_id' => Client::factory()->withContact(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ return new class extends Migration
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->string('abbreviation')->nullable();
|
$table->string('abbreviation')->nullable();
|
||||||
$table->date('audition_date');
|
$table->date('audition_date')->nullable();
|
||||||
$table->string('status')->default('active');
|
$table->string('status')->default('active');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ return new class extends Migration
|
||||||
{
|
{
|
||||||
Schema::create('client_contact', function (Blueprint $table) {
|
Schema::create('client_contact', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignIdFor(Client::class)->constrained();
|
$table->foreignIdFor(Client::class)->constrained()->cascadeOnDelete();
|
||||||
$table->foreignIdFor(Contact::class)->constrained();
|
$table->foreignIdFor(Contact::class)->constrained()->cascadeOnDelete();
|
||||||
$table->boolean('is_primary')->default(false);
|
$table->boolean('is_primary')->default(false);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
<x-layouts::app :title="__('Clients')">
|
<x-layouts::app :title="__('Clients')">
|
||||||
|
<div class="max-w-7xl mx-auto space-y-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<livewire:create-client />
|
||||||
|
</div>
|
||||||
|
<livewire:client-list />
|
||||||
|
<livewire:edit-client />
|
||||||
|
<livewire:add-client-contact />
|
||||||
|
<livewire:remove-client-contact />
|
||||||
|
<livewire:set-primary-contact />
|
||||||
|
</div>
|
||||||
</x-layouts::app>
|
</x-layouts::app>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public ?int $clientId = null;
|
||||||
|
public ?Client $client = null;
|
||||||
|
|
||||||
|
public ?int $contactId = null;
|
||||||
|
public bool $isPrimary = false;
|
||||||
|
|
||||||
|
// For creating new contact
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $first_name = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $last_name = '';
|
||||||
|
|
||||||
|
#[Validate('required|email|max:255|unique:contacts,email')]
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|string|max:20')]
|
||||||
|
public string $phone = '';
|
||||||
|
|
||||||
|
public bool $newContactIsPrimary = false;
|
||||||
|
|
||||||
|
#[On('add-client-contact')]
|
||||||
|
public function open(int $clientId): void
|
||||||
|
{
|
||||||
|
$this->clientId = $clientId;
|
||||||
|
$this->client = Client::findOrFail($clientId);
|
||||||
|
$this->contactId = null;
|
||||||
|
$this->isPrimary = !$this->client->contacts()->exists();
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
Flux::modal('add-contact')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function availableContacts()
|
||||||
|
{
|
||||||
|
if (!$this->client) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingContactIds = $this->client->contacts()->pluck('contacts.id');
|
||||||
|
|
||||||
|
return Contact::whereNotIn('id', $existingContactIds)
|
||||||
|
->orderBy('last_name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openCreateModal(): void
|
||||||
|
{
|
||||||
|
$this->first_name = '';
|
||||||
|
$this->last_name = '';
|
||||||
|
$this->email = '';
|
||||||
|
$this->phone = '';
|
||||||
|
$this->newContactIsPrimary = !$this->client->contacts()->exists();
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
Flux::modal('add-contact')->close();
|
||||||
|
Flux::modal('create-client-contact')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backToSelect(): void
|
||||||
|
{
|
||||||
|
Flux::modal('create-client-contact')->close();
|
||||||
|
Flux::modal('add-contact')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachContact(): void
|
||||||
|
{
|
||||||
|
if (!$this->contactId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPrimary) {
|
||||||
|
$this->client->contacts()->updateExistingPivot(
|
||||||
|
$this->client->contacts()->wherePivot('is_primary', true)->pluck('contacts.id'),
|
||||||
|
['is_primary' => false]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->contacts()->attach($this->contactId, ['is_primary' => $this->isPrimary]);
|
||||||
|
|
||||||
|
$this->reset(['clientId', 'client', 'contactId', 'isPrimary']);
|
||||||
|
Flux::modal('add-contact')->close();
|
||||||
|
$this->dispatch('client-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createAndAttach(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'first_name' => 'required|string|max:255',
|
||||||
|
'last_name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|max:255|unique:contacts,email',
|
||||||
|
'phone' => 'nullable|string|max:20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contact = Contact::create([
|
||||||
|
'first_name' => $this->first_name,
|
||||||
|
'last_name' => $this->last_name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'phone' => $this->phone ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($this->newContactIsPrimary) {
|
||||||
|
$this->client->contacts()->updateExistingPivot(
|
||||||
|
$this->client->contacts()->wherePivot('is_primary', true)->pluck('contacts.id'),
|
||||||
|
['is_primary' => false]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->contacts()->attach($contact->id, ['is_primary' => $this->newContactIsPrimary]);
|
||||||
|
|
||||||
|
$this->reset(['clientId', 'client', 'contactId', 'isPrimary', 'first_name', 'last_name', 'email', 'phone', 'newContactIsPrimary']);
|
||||||
|
Flux::modal('create-client-contact')->close();
|
||||||
|
$this->dispatch('client-updated');
|
||||||
|
$this->dispatch('contact-created');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function clientHasContacts(): bool
|
||||||
|
{
|
||||||
|
return $this->client?->contacts()->exists() ?? false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal name="add-contact" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<flux:heading size="lg">Add Contact to {{ $client?->name }}</flux:heading>
|
||||||
|
|
||||||
|
<flux:select label="Select Contact" wire:model.live="contactId" placeholder="Choose a contact...">
|
||||||
|
@foreach($this->availableContacts as $contact)
|
||||||
|
<flux:select.option value="{{ $contact->id }}">
|
||||||
|
{{ $contact->full_name }} ({{ $contact->email }})
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:button variant="subtle" wire:click="openCreateModal" icon="plus" class="w-full">
|
||||||
|
Create New Contact
|
||||||
|
</flux:button>
|
||||||
|
|
||||||
|
@if($this->clientHasContacts)
|
||||||
|
<flux:checkbox wire:model="isPrimary" label="Set as primary contact" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="button" variant="primary" wire:click="attachContact" :disabled="!$contactId">
|
||||||
|
Add Contact
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
<flux:modal name="create-client-contact" class="md:w-96">
|
||||||
|
<form wire:submit="createAndAttach" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Create Contact for {{ $client?->name }}</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="First Name" wire:model="first_name" />
|
||||||
|
<flux:input label="Last Name" wire:model="last_name" />
|
||||||
|
<flux:input label="Email" wire:model="email" type="email" />
|
||||||
|
<flux:input label="Phone" wire:model="phone" type="tel" />
|
||||||
|
|
||||||
|
@if($this->clientHasContacts)
|
||||||
|
<flux:checkbox wire:model="newContactIsPrimary" label="Set as primary contact" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:button type="button" variant="ghost" wire:click="backToSelect">Back</flux:button>
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Create & Add</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ClientStatus;
|
||||||
|
use App\Models\Client;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $sortBy = 'abbreviation';
|
||||||
|
public string $sortDirection = 'desc';
|
||||||
|
|
||||||
|
public function sort($column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeStatus(Client $client): void
|
||||||
|
{
|
||||||
|
$client->status = $client->status === ClientStatus::ACTIVE
|
||||||
|
? ClientStatus::INACTIVE
|
||||||
|
: ClientStatus::ACTIVE;
|
||||||
|
$client->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('client-created')]
|
||||||
|
#[On('client-updated')]
|
||||||
|
public function refresh(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function clients()
|
||||||
|
{
|
||||||
|
return Client::orderBy($this->sortBy, $this->sortDirection)->paginate(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!--suppress RequiredAttributes -->
|
||||||
|
<div>
|
||||||
|
<flux:table :paginate="$this->clients">
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('name')">
|
||||||
|
Name
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'abbreviation'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('abbreviation')">
|
||||||
|
Abbreviation
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column>
|
||||||
|
Contacts
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'audition_date'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('audition_date')">
|
||||||
|
Audition Date
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'status'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('status')">
|
||||||
|
Status
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('created_at')">
|
||||||
|
Created
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column></flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@foreach($this->clients as $client)
|
||||||
|
<flux:table.row :key="$client->id">
|
||||||
|
<flux:table.cell>{{ $client->name }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $client->abbreviation ?? '' }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@if($client->primary_contact)
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{{ $client->primary_contact?->full_name }}
|
||||||
|
<flux:icon.star variant="micro"/>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@foreach($client->secondaryContacts as $contact)
|
||||||
|
<p>{{ $contact->full_name }}</p>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $client->audition_date?->local()->format('m/d/Y') ?? '' }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge :color="$client->status->color()">
|
||||||
|
{{ $client->status->value }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $client->created_at->local()->format('m/d/Y | g:i A') }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:dropdown position="bottom" align="start">
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal"
|
||||||
|
inset="top bottom"></flux:button>
|
||||||
|
|
||||||
|
<flux:navmenu>
|
||||||
|
<flux:menu.group heading="{{ $client->abbreviation }}">
|
||||||
|
<flux:menu.separator></flux:menu.separator>
|
||||||
|
<flux:menu.item
|
||||||
|
wire:click="$dispatch('edit-client', { clientId: {{ $client->id }} })"
|
||||||
|
icon="pencil">Edit Client
|
||||||
|
</flux:menu.item>
|
||||||
|
@if($client->status === ClientStatus::ACTIVE)
|
||||||
|
<flux:menu.item
|
||||||
|
wire:click="changeStatus({{ $client }})"
|
||||||
|
icon="minus-circle">Make Inactive
|
||||||
|
</flux:menu.item>
|
||||||
|
@else
|
||||||
|
<flux:menu.item
|
||||||
|
wire:click="changeStatus({{ $client }})"
|
||||||
|
icon="plus-circle">Make Active
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
</flux:menu.group>
|
||||||
|
<flux:menu.group heading="Contacts">
|
||||||
|
<flux:menu.item
|
||||||
|
wire:click="$dispatch('add-client-contact', { clientId: {{ $client->id }} })"
|
||||||
|
icon="user-plus">Add Contact
|
||||||
|
</flux:menu.item>
|
||||||
|
@if($client->contacts()->count() > 0)
|
||||||
|
<flux:menu.item
|
||||||
|
wire:click="$dispatch('remove-client-contact', { clientId: {{ $client->id }} })"
|
||||||
|
icon="user-minus">Remove Contact
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
@if($client->contacts()->count() > 1)
|
||||||
|
<flux:menu.item
|
||||||
|
wire:click="$dispatch('set-primary-contact', { clientId: {{ $client->id }} })"
|
||||||
|
icon="user">Set Primary Contact
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
|
</flux:menu.group>
|
||||||
|
</flux:navmenu>
|
||||||
|
</flux:dropdown>
|
||||||
|
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $sortBy = 'last_name';
|
||||||
|
public string $sortDirection = 'asc';
|
||||||
|
|
||||||
|
public function sort($column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('contact-created')]
|
||||||
|
#[On('contact-updated')]
|
||||||
|
public function refresh(): void {}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function contacts()
|
||||||
|
{
|
||||||
|
return Contact::orderBy($this->sortBy, $this->sortDirection)->paginate(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!--suppress RequiredAttributes -->
|
||||||
|
<div>
|
||||||
|
<flux:table :paginate="$this->contacts">
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'first_name'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('first_name')">
|
||||||
|
First Name
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'last_name'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('last_name')">
|
||||||
|
Last Name
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'email'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('email')">
|
||||||
|
Email
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'phone'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('phone')">
|
||||||
|
Phone
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('created_at')">
|
||||||
|
Created
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column></flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@foreach($this->contacts as $contact)
|
||||||
|
<flux:table.row :key="$contact->id">
|
||||||
|
<flux:table.cell>{{ $contact->first_name }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $contact->last_name }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $contact->email }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $contact->phone }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $contact->created_at->local()->format('m/d/Y | g:i A') }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:dropdown position="bottom" align="start">
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal"
|
||||||
|
inset="top bottom"></flux:button>
|
||||||
|
|
||||||
|
<flux:navmenu>
|
||||||
|
<flux:menu.group heading="{{ $contact->first_name }} {{ $contact->last_name }}">
|
||||||
|
<flux:menu.separator></flux:menu.separator>
|
||||||
|
<flux:menu.item wire:click="$dispatch('edit-contact', { contactId: {{ $contact->id }} })" icon="pencil">Edit</flux:menu.item>
|
||||||
|
</flux:menu.group>
|
||||||
|
</flux:navmenu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
#[Validate('required|string|max:255|unique:clients,name')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:10|unique:clients,abbreviation')]
|
||||||
|
public string $abbreviation = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|date|after_or_equal:today')]
|
||||||
|
public ?string $audition_date = null;
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
Client::create([
|
||||||
|
'name' => $this->name,
|
||||||
|
'abbreviation' => $this->abbreviation,
|
||||||
|
'audition_date' => $this->audition_date ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('create-client')->close();
|
||||||
|
$this->dispatch('client-created');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal.trigger name="create-client">
|
||||||
|
<flux:button icon="plus" variant="primary">
|
||||||
|
New Client
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
|
||||||
|
<flux:modal name="create-client" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Create Client</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="Name" wire:model="name" />
|
||||||
|
<flux:input label="Abbreviation" wire:model="abbreviation" maxlength="10" />
|
||||||
|
<flux:input label="Audition Date" wire:model="audition_date" type="date" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
{{-- <flux:button variant="ghost" wire:click="$flux.modal('create-client').close()">Cancel</flux:button>--}}
|
||||||
|
<flux:button type="submit" variant="primary">Create</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $first_name = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $last_name = '';
|
||||||
|
|
||||||
|
#[Validate('required|email|max:255|unique:contacts,email')]
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|string|max:20')]
|
||||||
|
public string $phone = '';
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
Contact::create([
|
||||||
|
'first_name' => $this->first_name,
|
||||||
|
'last_name' => $this->last_name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'phone' => $this->phone ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('create-contact')->close();
|
||||||
|
$this->dispatch('contact-created');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal.trigger name="create-contact">
|
||||||
|
<flux:button icon="plus" variant="primary">
|
||||||
|
New Contact
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
<flux:modal name="create-contact" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Create Contact</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="First Name" wire:model="first_name" />
|
||||||
|
<flux:input label="Last Name" wire:model="last_name" />
|
||||||
|
<flux:input label="Email" wire:model="email" type="email" />
|
||||||
|
<flux:input label="Phone" wire:model="phone" type="tel" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Create</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\InvoiceStatus;
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
#[Validate('required|integer|exists:clients,id')]
|
||||||
|
public int $client_id;
|
||||||
|
|
||||||
|
public InvoiceStatus $status = InvoiceStatus::DRAFT;
|
||||||
|
|
||||||
|
#[Validate('nullable|string')]
|
||||||
|
public ?string $notes = null;
|
||||||
|
|
||||||
|
#[Validate('nullable|string')]
|
||||||
|
public ?string $internal_notes = null;
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
Invoice::create([
|
||||||
|
'client_id' => $this->client_id,
|
||||||
|
'status' => $this->status,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'internal_notes' => $this->notes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('create-invoice')->close();
|
||||||
|
$this->dispatch('invoice-created');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function clients()
|
||||||
|
{
|
||||||
|
return Client::where('status', 'active')->orderBy('abbreviation')->get();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal.trigger name="create-invoice">
|
||||||
|
<flux:button icon="plus" variant="primary">
|
||||||
|
Create Invoice
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
<flux:modal name="create-invoice" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Create Invoice</flux:heading>
|
||||||
|
|
||||||
|
<flux:select wire:model="client_id" label="Client" placeholder="Choose client...">
|
||||||
|
@foreach($this->clients as $client)
|
||||||
|
<flux:select.option :value="$client->id">{{ $client->name }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
<flux:textarea wire:model="notes" label="Notes" placeholder="Add notes to this invoice..."></flux:textarea>
|
||||||
|
<flux:textarea wire:model="internal_notes" label="Internal Notes" placeholder="Add internal notes to this invoice..."></flux:textarea>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Create</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
#[Validate('required|string|max:50|unique:products,sku')]
|
||||||
|
public string $sku = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|string|max:1000')]
|
||||||
|
public string $description = '';
|
||||||
|
|
||||||
|
#[Validate('required|numeric|min:0')]
|
||||||
|
public string $price = '';
|
||||||
|
|
||||||
|
#[Validate('boolean')]
|
||||||
|
public bool $active = true;
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
Product::create([
|
||||||
|
'sku' => $this->sku,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description ?: null,
|
||||||
|
'price' => $this->price,
|
||||||
|
'active' => $this->active,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('create-product')->close();
|
||||||
|
$this->dispatch('product-created');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal.trigger name="create-product">
|
||||||
|
<flux:button icon="plus" variant="primary">
|
||||||
|
New Product
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
<flux:modal name="create-product" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Create Product</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="SKU" wire:model="sku" />
|
||||||
|
<flux:input label="Name" wire:model="name" />
|
||||||
|
<flux:textarea label="Description" wire:model="description" rows="3" />
|
||||||
|
<flux:input label="Price" wire:model="price" type="number" step="0.01" min="0" />
|
||||||
|
<flux:switch label="Active" wire:model="active" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Create</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public ?int $clientId = null;
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:10')]
|
||||||
|
public string $abbreviation = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|date|after_or_equal:today')]
|
||||||
|
public ?string $audition_date = null;
|
||||||
|
|
||||||
|
#[On('edit-client')]
|
||||||
|
public function edit(int $clientId): void
|
||||||
|
{
|
||||||
|
$this->clientId = $clientId;
|
||||||
|
$client = Client::findOrFail($clientId);
|
||||||
|
|
||||||
|
$this->name = $client->name;
|
||||||
|
$this->abbreviation = $client->abbreviation;
|
||||||
|
$this->audition_date = $client->audition_date?->format('Y-m-d');
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
Flux::modal('edit-client')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:clients,name,' . $this->clientId,
|
||||||
|
'abbreviation' => 'required|string|max:10|unique:clients,abbreviation,' . $this->clientId,
|
||||||
|
'audition_date' => 'nullable|date|after_or_equal:today',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$client = Client::findOrFail($this->clientId);
|
||||||
|
$client->update([
|
||||||
|
'name' => $this->name,
|
||||||
|
'abbreviation' => $this->abbreviation,
|
||||||
|
'audition_date' => $this->audition_date ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('edit-client')->close();
|
||||||
|
$this->dispatch('client-updated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal name="edit-client" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Edit Client</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="Name" wire:model="name" />
|
||||||
|
<flux:input label="Abbreviation" wire:model="abbreviation" maxlength="10" />
|
||||||
|
<flux:input label="Audition Date" wire:model="audition_date" type="date" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Save</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public ?int $contactId = null;
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $first_name = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $last_name = '';
|
||||||
|
|
||||||
|
#[Validate('required|email|max:255')]
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|string|max:20')]
|
||||||
|
public string $phone = '';
|
||||||
|
|
||||||
|
#[On('edit-contact')]
|
||||||
|
public function edit(int $contactId): void
|
||||||
|
{
|
||||||
|
$this->contactId = $contactId;
|
||||||
|
$contact = Contact::findOrFail($contactId);
|
||||||
|
|
||||||
|
$this->first_name = $contact->first_name;
|
||||||
|
$this->last_name = $contact->last_name;
|
||||||
|
$this->email = $contact->email;
|
||||||
|
$this->phone = $contact->phone ?? '';
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
Flux::modal('edit-contact')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'first_name' => 'required|string|max:255',
|
||||||
|
'last_name' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|max:255|unique:contacts,email,' . $this->contactId,
|
||||||
|
'phone' => 'nullable|string|max:20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contact = Contact::findOrFail($this->contactId);
|
||||||
|
$contact->update([
|
||||||
|
'first_name' => $this->first_name,
|
||||||
|
'last_name' => $this->last_name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'phone' => $this->phone ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('edit-contact')->close();
|
||||||
|
$this->dispatch('contact-updated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal name="edit-contact" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Edit Contact</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="First Name" wire:model="first_name" />
|
||||||
|
<flux:input label="Last Name" wire:model="last_name" />
|
||||||
|
<flux:input label="Email" wire:model="email" type="email" />
|
||||||
|
<flux:input label="Phone" wire:model="phone" type="tel" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Save</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public ?int $productId = null;
|
||||||
|
|
||||||
|
#[Validate('required|string|max:50')]
|
||||||
|
public string $sku = '';
|
||||||
|
|
||||||
|
#[Validate('required|string|max:255')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|string|max:1000')]
|
||||||
|
public string $description = '';
|
||||||
|
|
||||||
|
#[Validate('required|numeric|min:0')]
|
||||||
|
public string $price = '';
|
||||||
|
|
||||||
|
#[Validate('boolean')]
|
||||||
|
public bool $active = true;
|
||||||
|
|
||||||
|
#[On('edit-product')]
|
||||||
|
public function edit(int $productId): void
|
||||||
|
{
|
||||||
|
$this->productId = $productId;
|
||||||
|
$product = Product::findOrFail($productId);
|
||||||
|
|
||||||
|
$this->sku = $product->sku;
|
||||||
|
$this->name = $product->name;
|
||||||
|
$this->description = $product->description ?? '';
|
||||||
|
$this->price = (string) $product->getRawOriginal('price') / 100;
|
||||||
|
$this->active = $product->active;
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
Flux::modal('edit-product')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'sku' => 'required|string|max:50|unique:products,sku,' . $this->productId,
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string|max:1000',
|
||||||
|
'price' => 'required|numeric|min:0',
|
||||||
|
'active' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = Product::findOrFail($this->productId);
|
||||||
|
$product->update([
|
||||||
|
'sku' => $this->sku,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description ?: null,
|
||||||
|
'price' => $this->price,
|
||||||
|
'active' => $this->active,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset();
|
||||||
|
Flux::modal('edit-product')->close();
|
||||||
|
$this->dispatch('product-updated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal name="edit-product" class="md:w-96">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
<flux:heading size="lg">Edit Product</flux:heading>
|
||||||
|
|
||||||
|
<flux:input label="SKU" wire:model="sku" />
|
||||||
|
<flux:input label="Name" wire:model="name" />
|
||||||
|
<flux:textarea label="Description" wire:model="description" rows="3" />
|
||||||
|
<flux:input label="Price" wire:model="price" type="number" step="0.01" min="0" />
|
||||||
|
<flux:switch label="Active" wire:model="active" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="submit" variant="primary">Save</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\InvoiceStatus;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $sortBy = 'created_at';
|
||||||
|
public string $sortDirection = 'desc';
|
||||||
|
|
||||||
|
public function sort($column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('invoice-created')]
|
||||||
|
public function refresh(): void {}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function invoices()
|
||||||
|
{
|
||||||
|
return Invoice::orderBy($this->sortBy, $this->sortDirection)->paginate(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!--suppress RequiredAttributes -->
|
||||||
|
<div>
|
||||||
|
<flux:table :pagination="$this->invoices">
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'invoice_number'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('invoice_number')">
|
||||||
|
Invoice Number
|
||||||
|
</flux:table.column>
|
||||||
|
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'client_id'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('client_id')">
|
||||||
|
Client
|
||||||
|
</flux:table.column>
|
||||||
|
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'status'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('status')">
|
||||||
|
Status
|
||||||
|
</flux:table.column>
|
||||||
|
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'invoice_date'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('invoice_date')">
|
||||||
|
Invoice Date
|
||||||
|
</flux:table.column>
|
||||||
|
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'sent_at'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('sent_at')">
|
||||||
|
Sent
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'due_date'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('due_date')">
|
||||||
|
Due Date
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'total'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('total')">
|
||||||
|
Total
|
||||||
|
</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@foreach($this->invoices as $invoice)
|
||||||
|
<flux:table.row :key="$invoice->id">
|
||||||
|
<flux:table.cell>{{ $invoice->invoice_number }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $invoice->client->abbreviation }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge :color="$invoice->status->color()" rounded size="sm">
|
||||||
|
{{ $invoice->status->label() }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $invoice->invoice_date?->format('m/d/Y') }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@if($invoice->sent_at)
|
||||||
|
<flux:badge color="green" rounded
|
||||||
|
size="sm">{{ $invoice->sent_at->format('m/d/Y') }}</flux:badge>
|
||||||
|
@elseif($invoice->status === InvoiceStatus::POSTED)
|
||||||
|
<flux:badge color="red" rounded size="sm">Not Sent</flux:badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
@if($invoice->due_date)
|
||||||
|
<flux:badge size="sm" rounded
|
||||||
|
:color="$invoice->due_date?->isPast() && $invoice->status === InvoiceStatus::POSTED ? 'red' : 'blue'">
|
||||||
|
{{ $invoice->due_date?->format('m/d/Y') }}
|
||||||
|
</flux:badge>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ formatMoney($invoice->total) }}</flux:table.cell>
|
||||||
|
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $sortBy = 'name';
|
||||||
|
public string $sortDirection = 'asc';
|
||||||
|
|
||||||
|
public function sort($column): void
|
||||||
|
{
|
||||||
|
if ($this->sortBy === $column) {
|
||||||
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sortBy = $column;
|
||||||
|
$this->sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[On('product-created')]
|
||||||
|
#[On('product-updated')]
|
||||||
|
public function refresh(): void {}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function products()
|
||||||
|
{
|
||||||
|
return Product::orderBy($this->sortBy, $this->sortDirection)->paginate(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!--suppress RequiredAttributes -->
|
||||||
|
<div>
|
||||||
|
<flux:table :paginate="$this->products">
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'sku'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('sku')">
|
||||||
|
SKU
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('name')">
|
||||||
|
Name
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'description'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('description')">
|
||||||
|
Description
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'price'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('price')">
|
||||||
|
Price
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column sortable :sorted="$sortBy === 'active'" :direction="$sortDirection"
|
||||||
|
wire:click="sort('active')">
|
||||||
|
Status
|
||||||
|
</flux:table.column>
|
||||||
|
<flux:table.column></flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
|
||||||
|
<flux:table.rows>
|
||||||
|
@foreach($this->products as $product)
|
||||||
|
<flux:table.row :key="$product->id">
|
||||||
|
<flux:table.cell>{{ $product->sku }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ $product->name }}</flux:table.cell>
|
||||||
|
<flux:table.cell class="max-w-xs truncate">{{ $product->description }}</flux:table.cell>
|
||||||
|
<flux:table.cell>{{ formatMoney($product->price) }}</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:badge :color="$product->active ? 'green' : 'zinc'">
|
||||||
|
{{ $product->active ? 'Active' : 'Inactive' }}
|
||||||
|
</flux:badge>
|
||||||
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<flux:dropdown position="bottom" align="start">
|
||||||
|
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal"
|
||||||
|
inset="top bottom"></flux:button>
|
||||||
|
|
||||||
|
<flux:navmenu>
|
||||||
|
<flux:menu.group heading="{{ $product->sku }}">
|
||||||
|
<flux:menu.separator></flux:menu.separator>
|
||||||
|
<flux:menu.item wire:click="$dispatch('edit-product', { productId: {{ $product->id }} })" icon="pencil">Edit</flux:menu.item>
|
||||||
|
</flux:menu.group>
|
||||||
|
</flux:navmenu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use App\Models\Contact;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public ?int $clientId = null;
|
||||||
|
public ?Client $client = null;
|
||||||
|
|
||||||
|
public ?int $contactId = null;
|
||||||
|
public ?int $newPrimaryId = null;
|
||||||
|
|
||||||
|
#[On('remove-client-contact')]
|
||||||
|
public function open(int $clientId): void
|
||||||
|
{
|
||||||
|
$this->clientId = $clientId;
|
||||||
|
$this->client = Client::findOrFail($clientId);
|
||||||
|
$this->contactId = null;
|
||||||
|
$this->newPrimaryId = null;
|
||||||
|
|
||||||
|
$this->resetValidation();
|
||||||
|
Flux::modal('remove-contact')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function clientContacts()
|
||||||
|
{
|
||||||
|
if (!$this->client) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->contacts()
|
||||||
|
->orderBy('last_name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function selectedContact(): ?Contact
|
||||||
|
{
|
||||||
|
if (!$this->contactId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Contact::find($this->contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function isRemovingPrimary(): bool
|
||||||
|
{
|
||||||
|
if (!$this->contactId || !$this->client) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->contacts()
|
||||||
|
->wherePivot('is_primary', true)
|
||||||
|
->where('contacts.id', $this->contactId)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function otherContacts()
|
||||||
|
{
|
||||||
|
if (!$this->client || !$this->contactId) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->contacts()
|
||||||
|
->where('contacts.id', '!=', $this->contactId)
|
||||||
|
->orderBy('last_name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function needsNewPrimarySelection(): bool
|
||||||
|
{
|
||||||
|
return $this->isRemovingPrimary && $this->otherContacts->count() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeContact(): void
|
||||||
|
{
|
||||||
|
if (!$this->contactId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$otherContacts = $this->otherContacts;
|
||||||
|
|
||||||
|
// Detach the selected contact
|
||||||
|
$this->client->contacts()->detach($this->contactId);
|
||||||
|
|
||||||
|
// Handle primary contact assignment
|
||||||
|
if ($otherContacts->count() === 1) {
|
||||||
|
// Only one remaining - make them primary
|
||||||
|
$this->client->contacts()->updateExistingPivot(
|
||||||
|
$otherContacts->first()->id,
|
||||||
|
['is_primary' => true]
|
||||||
|
);
|
||||||
|
} elseif ($otherContacts->count() > 1 && $this->isRemovingPrimary) {
|
||||||
|
// Multiple remaining and removing primary - use selected new primary
|
||||||
|
if ($this->newPrimaryId) {
|
||||||
|
// Clear any existing primary
|
||||||
|
$this->client->contacts()->wherePivot('is_primary', true)
|
||||||
|
->each(fn ($contact) => $this->client->contacts()->updateExistingPivot(
|
||||||
|
$contact->id,
|
||||||
|
['is_primary' => false]
|
||||||
|
));
|
||||||
|
|
||||||
|
// Set new primary
|
||||||
|
$this->client->contacts()->updateExistingPivot(
|
||||||
|
$this->newPrimaryId,
|
||||||
|
['is_primary' => true]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset(['clientId', 'client', 'contactId', 'newPrimaryId']);
|
||||||
|
Flux::modal('remove-contact')->close();
|
||||||
|
$this->dispatch('client-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function canSubmit(): bool
|
||||||
|
{
|
||||||
|
if (!$this->contactId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->needsNewPrimarySelection && !$this->newPrimaryId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal name="remove-contact" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<flux:heading size="lg">Remove Contact from {{ $client?->name }}</flux:heading>
|
||||||
|
|
||||||
|
@if($this->clientContacts->isEmpty())
|
||||||
|
<p class="text-zinc-500">This client has no contacts.</p>
|
||||||
|
@else
|
||||||
|
<flux:select label="Select Contact to Remove" wire:model.live="contactId" placeholder="Choose a contact...">
|
||||||
|
@foreach($this->clientContacts as $contact)
|
||||||
|
<flux:select.option value="{{ $contact->id }}">
|
||||||
|
{{ $contact->full_name }}
|
||||||
|
@if($contact->pivot->is_primary) (Primary) @endif
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
@if($this->needsNewPrimarySelection)
|
||||||
|
<flux:radio.group wire:model.live="newPrimaryId" label="Select New Primary Contact">
|
||||||
|
@foreach($this->otherContacts as $contact)
|
||||||
|
<flux:radio value="{{ $contact->id }}" label="{{ $contact->full_name }}" />
|
||||||
|
@endforeach
|
||||||
|
</flux:radio.group>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
wire:click="removeContact"
|
||||||
|
:disabled="!$this->canSubmit"
|
||||||
|
>
|
||||||
|
Remove Contact
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Client;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public ?int $clientId = null;
|
||||||
|
public ?Client $client = null;
|
||||||
|
public ?int $primaryId = null;
|
||||||
|
|
||||||
|
#[On('set-primary-contact')]
|
||||||
|
public function open(int $clientId): void
|
||||||
|
{
|
||||||
|
$this->clientId = $clientId;
|
||||||
|
$this->client = Client::findOrFail($clientId);
|
||||||
|
$this->primaryId = $this->client->contacts()
|
||||||
|
->wherePivot('is_primary', true)
|
||||||
|
->first()?->id;
|
||||||
|
|
||||||
|
Flux::modal('set-primary-contact')->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function clientContacts()
|
||||||
|
{
|
||||||
|
if (!$this->client) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->contacts()
|
||||||
|
->orderBy('last_name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
if (!$this->primaryId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing primary
|
||||||
|
$this->client->contacts()->wherePivot('is_primary', true)
|
||||||
|
->each(fn ($contact) => $this->client->contacts()->updateExistingPivot(
|
||||||
|
$contact->id,
|
||||||
|
['is_primary' => false]
|
||||||
|
));
|
||||||
|
|
||||||
|
// Set new primary
|
||||||
|
$this->client->contacts()->updateExistingPivot(
|
||||||
|
$this->primaryId,
|
||||||
|
['is_primary' => true]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->reset(['clientId', 'client', 'primaryId']);
|
||||||
|
Flux::modal('set-primary-contact')->close();
|
||||||
|
$this->dispatch('client-updated');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<flux:modal name="set-primary-contact" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<flux:heading size="lg">Set Primary Contact for {{ $client?->name }}</flux:heading>
|
||||||
|
|
||||||
|
<flux:radio.group wire:model="primaryId" label="Select Primary Contact">
|
||||||
|
@foreach($this->clientContacts as $contact)
|
||||||
|
<flux:radio value="{{ $contact->id }}" label="{{ $contact->full_name }}" />
|
||||||
|
@endforeach
|
||||||
|
</flux:radio.group>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer />
|
||||||
|
<flux:button type="button" variant="primary" wire:click="save">
|
||||||
|
Save
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<x-layouts::app :title="__('Contacts')">
|
||||||
|
<div class="max-w-7xl mx-auto space-y-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<livewire:create-contact />
|
||||||
|
</div>
|
||||||
|
<livewire:contact-list />
|
||||||
|
<livewire:edit-contact />
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<x-layouts::app :title="__('Contacts')">
|
||||||
|
<div class="max-w-7xl mx-auto space-y-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<livewire:create-invoice />
|
||||||
|
</div>
|
||||||
|
<livewire:invoice-list />
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||||
|
<!--suppress HtmlRequiredTitleElement -->
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -16,10 +17,22 @@
|
||||||
{{ __('Dashboard') }}
|
{{ __('Dashboard') }}
|
||||||
</flux:sidebar.item>
|
</flux:sidebar.item>
|
||||||
|
|
||||||
<flux:sidebar.item icon="user" :href="route('clients.index')" :current="request()->routeIs('clients.*')" wire:navigate>
|
<flux:sidebar.item icon="musical-note" :href="route('clients')" :current="request()->routeIs('clients.*')" wire:navigate>
|
||||||
{{ __('Clients') }}
|
{{ __('Clients') }}
|
||||||
</flux:sidebar.item>
|
</flux:sidebar.item>
|
||||||
|
|
||||||
|
<flux:sidebar.item icon="user" :href="route('contacts')" :current="request()->routeIs('contacts.*')" wire:navigate>
|
||||||
|
{{ __('Contacts') }}
|
||||||
|
</flux:sidebar.item>
|
||||||
|
|
||||||
|
<flux:sidebar.item icon="archive-box" :href="route('products')" :current="request()->routeIs('products.*')" wire:navigate>
|
||||||
|
{{ __('Products') }}
|
||||||
|
</flux:sidebar.item>
|
||||||
|
|
||||||
|
<flux:sidebar.item icon="document-currency-dollar" :href="route('invoices')" :current="request()->routeIs('invoices.*')" wire:navigate>
|
||||||
|
{{ __('Invoices') }}
|
||||||
|
</flux:sidebar.item>
|
||||||
|
|
||||||
</flux:sidebar.group>
|
</flux:sidebar.group>
|
||||||
</flux:sidebar.nav>
|
</flux:sidebar.nav>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<x-layouts::app :title="__('Products')">
|
||||||
|
<div class="max-w-7xl mx-auto space-y-4">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<livewire:create-product />
|
||||||
|
</div>
|
||||||
|
<livewire:product-list />
|
||||||
|
<livewire:edit-product />
|
||||||
|
</div>
|
||||||
|
</x-layouts::app>
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\ClientController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
return view('welcome');
|
||||||
})->name('home');
|
})->name('home');
|
||||||
|
|
||||||
Route::view('dashboard', 'dashboard')
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
->middleware(['auth', 'verified'])
|
Route::view('dashboard', 'dashboard')->name('dashboard');
|
||||||
->name('dashboard');
|
Route::view('clients', 'clients.index')->name('clients');
|
||||||
|
Route::view('contacts', 'contacts.index')->name('contacts');
|
||||||
|
Route::view('products', 'products.index')->name('products');
|
||||||
|
Route::view('invoices', 'invoices.index')->name('invoices');
|
||||||
|
});
|
||||||
|
|
||||||
Route::view('clients', 'clients.index')
|
// Route::view('dashboard', 'dashboard')
|
||||||
->middleware(['auth', 'verified'])
|
// ->middleware(['auth', 'verified'])
|
||||||
->name('clients.index');
|
// ->name('dashboard');
|
||||||
|
|
||||||
|
// Route::view('clients', 'clients.index')
|
||||||
|
// ->middleware(['auth', 'verified'])
|
||||||
|
// ->name('clients.index');
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue