Compare commits
No commits in common. "2a0b99c599cea7b256e0249f5f5afceb2852a79a" and "6539d7dd2dd5b12a2840b7fd34f375cb1352d5c8" have entirely different histories.
2a0b99c599
...
6539d7dd2d
23
.env.testing
23
.env.testing
@ -1,23 +0,0 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
|
||||
LOG_CHANNEL=daily
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=pangestu_dst_store_testing
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=Aggygga13@myadmin
|
||||
|
||||
CACHE_STORE=array
|
||||
DEVELOPMENT_CACHE_STORE=array
|
||||
SESSION_DRIVER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
MAIL_MAILER=array
|
||||
@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\HasValues;
|
||||
|
||||
enum InvoiceStatus: string
|
||||
{
|
||||
use HasValues;
|
||||
|
||||
case UNPAID = 'unpaid';
|
||||
case PAID = 'paid';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::UNPAID => 'Belum Lunas',
|
||||
self::PAID => 'Lunas',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\InvoiceRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Admin\Manage\InvoiceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceService $service
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/invoice/index', [
|
||||
'invoices' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/invoice/create', [
|
||||
'statusOptions' => InvoiceStatus::toSelect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(InvoiceRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->store($request->validated()),
|
||||
'Invoice berhasil ditambahkan.',
|
||||
'admin.manage.invoices.index',
|
||||
'admin.manage.invoices.create'
|
||||
);
|
||||
}
|
||||
|
||||
public function edit(Invoice $invoice): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/invoice/edit', [
|
||||
'invoice' => $this->service->getForEdit($invoice),
|
||||
'statusOptions' => InvoiceStatus::toSelect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(Invoice $invoice): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/invoice/print', [
|
||||
'invoice' => $this->service->getForPrint($invoice),
|
||||
]);
|
||||
}
|
||||
|
||||
public function share(Invoice $invoice): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/invoice/print', [
|
||||
'invoice' => $this->service->getForPrint($invoice),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(InvoiceRequest $request, Invoice $invoice): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->update($invoice, $request->validated()),
|
||||
'Invoice berhasil diperbarui.',
|
||||
'admin.manage.invoices.index',
|
||||
'admin.manage.invoices.edit',
|
||||
['invoice' => $invoice]
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Invoice $invoice): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->destroy($invoice),
|
||||
'Invoice berhasil dihapus.',
|
||||
'admin.manage.invoices.index'
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvoiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('invoices.create')
|
||||
|| $this->user()->can('invoices.update');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$invoice = $this->route('invoice');
|
||||
|
||||
return [
|
||||
'invoice_number' => ['required', 'string', 'max:50', Rule::unique('invoices', 'invoice_number')->ignore($invoice)],
|
||||
'customer_name' => ['required', 'string', 'max:200'],
|
||||
'customer_phone' => ['nullable', 'string', 'max:20'],
|
||||
'customer_address' => ['nullable', 'string'],
|
||||
'date' => ['required', 'date'],
|
||||
'due_date' => ['nullable', 'date', 'after_or_equal:date'],
|
||||
'status' => ['required', Rule::in(InvoiceStatus::values())],
|
||||
'description' => ['nullable', 'string'],
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.name' => ['required', 'string', 'max:200'],
|
||||
'items.*.quantity' => ['required', 'integer', 'min:1'],
|
||||
'items.*.price' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'invoice_number' => 'nomor invoice',
|
||||
'customer_name' => 'nama pelanggan',
|
||||
'customer_phone' => 'no. telepon pelanggan',
|
||||
'customer_address' => 'alamat pelanggan',
|
||||
'date' => 'tanggal',
|
||||
'due_date' => 'jatuh tempo',
|
||||
'status' => 'status',
|
||||
'description' => 'keterangan',
|
||||
'items' => 'item invoice',
|
||||
'items.*.name' => 'nama item',
|
||||
'items.*.quantity' => 'jumlah item',
|
||||
'items.*.price' => 'harga item',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -17,11 +17,7 @@ public function handle(MessageLogged $event): void
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$developers = User::role('Developer')->where('is_active', true)->get();
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
$developers = User::role('Developer')->where('is_active', true)->get();
|
||||
|
||||
if ($developers->isEmpty()) {
|
||||
return;
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Appends(['formatted_amount', 'formatted_date', 'formatted_due_date', 'status_label'])]
|
||||
#[Guarded(['id'])]
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'integer',
|
||||
'date' => 'date:Y-m-d',
|
||||
'due_date' => 'date:Y-m-d',
|
||||
'status' => InvoiceStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function formattedAmount(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function formattedDate(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->date?->translatedFormat('l, d F Y'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function formattedDueDate(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->due_date?->translatedFormat('l, d F Y'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function statusLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->status->label(),
|
||||
);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvoiceItem::class);
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class InvoiceItem extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => 'integer',
|
||||
'price' => 'integer',
|
||||
'subtotal' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Settings\SystemSettings;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvoiceService
|
||||
{
|
||||
use LogsFormHistory;
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$itemsCountQuery = '(SELECT COUNT(*) FROM invoice_items WHERE invoice_items.invoice_id = invoices.id)';
|
||||
|
||||
return Invoice::query()
|
||||
->select(['id', 'invoice_number', 'customer_name', 'amount', 'date', 'due_date', 'status', 'description'])
|
||||
->selectRaw("{$itemsCountQuery} as items_count")
|
||||
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
||||
$query->where('invoice_number', 'like', "%{$search}%")
|
||||
->orWhere('customer_name', 'like', "%{$search}%");
|
||||
}))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function getForEdit(Invoice $invoice): array
|
||||
{
|
||||
$invoice->load('items');
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'customer_name' => $invoice->customer_name,
|
||||
'customer_phone' => $invoice->customer_phone,
|
||||
'customer_address' => $invoice->customer_address,
|
||||
'date' => $invoice->date->format('Y-m-d'),
|
||||
'due_date' => $invoice->due_date?->format('Y-m-d'),
|
||||
'status' => $invoice->status->value,
|
||||
'description' => $invoice->description,
|
||||
'items' => $invoice->items->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function getForPrint(Invoice $invoice): array
|
||||
{
|
||||
$invoice->load('items');
|
||||
$settings = app(SystemSettings::class);
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'customer_name' => $invoice->customer_name,
|
||||
'customer_phone' => $invoice->customer_phone,
|
||||
'customer_address' => $invoice->customer_address,
|
||||
'amount' => $invoice->amount,
|
||||
'formatted_amount' => $invoice->formatted_amount,
|
||||
'formatted_date' => $invoice->formatted_date,
|
||||
'formatted_due_date' => $invoice->formatted_due_date,
|
||||
'status' => $invoice->status->value,
|
||||
'status_label' => $invoice->status_label,
|
||||
'description' => $invoice->description,
|
||||
'items' => $invoice->items->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
'subtotal' => $item->subtotal,
|
||||
]),
|
||||
'company' => [
|
||||
'name' => $settings->app_name,
|
||||
'address' => $settings->address,
|
||||
'email' => $settings->email,
|
||||
'phone' => $settings->phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function store(array $data): Invoice
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$now = now();
|
||||
$amount = 0;
|
||||
$itemRows = $this->buildItemRows($data['items'], $now, $amount);
|
||||
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => $data['invoice_number'],
|
||||
'customer_name' => $data['customer_name'],
|
||||
'customer_phone' => $data['customer_phone'] ?? null,
|
||||
'customer_address' => $data['customer_address'] ?? null,
|
||||
'amount' => $amount,
|
||||
'date' => $data['date'],
|
||||
'due_date' => $data['due_date'] ?? null,
|
||||
'status' => $data['status'],
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($itemRows as &$row) {
|
||||
$row['invoice_id'] = $invoice->id;
|
||||
}
|
||||
DB::table('invoice_items')->insert($itemRows);
|
||||
|
||||
$this->logCreated($invoice, 'Invoice', [
|
||||
'Nomor Invoice' => $invoice->invoice_number,
|
||||
'Nama Pelanggan' => $invoice->customer_name,
|
||||
'Jumlah' => $invoice->amount,
|
||||
'Status' => $invoice->status->value,
|
||||
]);
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Invoice $invoice, array $data): Invoice
|
||||
{
|
||||
return DB::transaction(function () use ($invoice, $data) {
|
||||
$oldValues = [
|
||||
'Nomor Invoice' => $invoice->invoice_number,
|
||||
'Nama Pelanggan' => $invoice->customer_name,
|
||||
'Jumlah' => $invoice->amount,
|
||||
'Status' => $invoice->status->value,
|
||||
];
|
||||
|
||||
$invoice->items()->delete();
|
||||
|
||||
$now = now();
|
||||
$amount = 0;
|
||||
$itemRows = $this->buildItemRows($data['items'], $now, $amount);
|
||||
|
||||
foreach ($itemRows as &$row) {
|
||||
$row['invoice_id'] = $invoice->id;
|
||||
}
|
||||
DB::table('invoice_items')->insert($itemRows);
|
||||
|
||||
$invoice->update([
|
||||
'invoice_number' => $data['invoice_number'],
|
||||
'customer_name' => $data['customer_name'],
|
||||
'customer_phone' => $data['customer_phone'] ?? null,
|
||||
'customer_address' => $data['customer_address'] ?? null,
|
||||
'amount' => $amount,
|
||||
'date' => $data['date'],
|
||||
'due_date' => $data['due_date'] ?? null,
|
||||
'status' => $data['status'],
|
||||
'description' => $data['description'] ?? null,
|
||||
]);
|
||||
|
||||
$this->logUpdated($invoice, 'Invoice', $oldValues, [
|
||||
'Nomor Invoice' => $invoice->invoice_number,
|
||||
'Nama Pelanggan' => $invoice->customer_name,
|
||||
'Jumlah' => $invoice->amount,
|
||||
'Status' => $invoice->status->value,
|
||||
]);
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Invoice $invoice): bool
|
||||
{
|
||||
return DB::transaction(function () use ($invoice) {
|
||||
$this->logDeleted($invoice, 'Invoice', [
|
||||
'Nomor Invoice' => $invoice->invoice_number,
|
||||
'Nama Pelanggan' => $invoice->customer_name,
|
||||
'Jumlah' => $invoice->amount,
|
||||
'Status' => $invoice->status->value,
|
||||
]);
|
||||
|
||||
$invoice->items()->delete();
|
||||
|
||||
return $invoice->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function buildItemRows(array $items, $now, int &$amount): array
|
||||
{
|
||||
return collect($items)->map(function ($item) use ($now, &$amount) {
|
||||
$quantity = (int) $item['quantity'];
|
||||
$price = (int) $item['price'];
|
||||
$subtotal = $quantity * $price;
|
||||
$amount += $subtotal;
|
||||
|
||||
return [
|
||||
'invoice_id' => null,
|
||||
'name' => $item['name'],
|
||||
'quantity' => $quantity,
|
||||
'price' => $price,
|
||||
'subtotal' => $subtotal,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class InvoiceFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'invoice_number' => 'INV-'.fake()->unique()->numerify('######'),
|
||||
'customer_name' => fake()->name(),
|
||||
'amount' => fake()->numberBetween(50000, 5000000),
|
||||
'date' => fake()->dateTimeBetween('-1 month', 'now')->format('Y-m-d'),
|
||||
'due_date' => fake()->dateTimeBetween('now', '+1 month')->format('Y-m-d'),
|
||||
'status' => fake()->randomElement(['unpaid', 'paid']),
|
||||
'description' => fake()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
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('invoices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string('invoice_number', 50)->unique();
|
||||
$table->string('customer_name', 200);
|
||||
$table->string('customer_phone', 20)->nullable();
|
||||
$table->text('customer_address')->nullable();
|
||||
$table->integer('amount');
|
||||
$table->date('date');
|
||||
$table->date('due_date')->nullable();
|
||||
$table->enum('status', InvoiceStatus::values())->default(InvoiceStatus::UNPAID->value);
|
||||
$table->text('description')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('invoices');
|
||||
}
|
||||
};
|
||||
@ -1,34 +0,0 @@
|
||||
<?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('invoice_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('invoice_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 200);
|
||||
$table->integer('quantity');
|
||||
$table->integer('price');
|
||||
$table->integer('subtotal');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('invoice_items');
|
||||
}
|
||||
};
|
||||
@ -35,7 +35,6 @@ public function run(): void
|
||||
'suppliers' => ['view', 'create', 'update', 'delete'],
|
||||
'purchases' => ['view', 'create', 'update', 'delete'],
|
||||
'stok_opnames' => ['view', 'create', 'update', 'delete', 'submit', 'verify'],
|
||||
'invoices' => ['view', 'create', 'update', 'delete'],
|
||||
'roles' => ['view', 'create', 'update', 'delete'],
|
||||
'settings' => ['view_system', 'update_system', 'view_homepage', 'update_homepage', 'view_social_media', 'update_social_media', 'view_hr', 'update_hr'],
|
||||
];
|
||||
|
||||
@ -17,15 +17,14 @@
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:ZzYbvuGT/I9ZLB6iTToDToCPrqGgq0RysyUUbE/jBSQ="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="mysql"/>
|
||||
<env name="DB_DATABASE" value="pangestu_local_dst_store"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
@ -34,5 +33,4 @@
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
|
||||
</phpunit>
|
||||
|
||||
@ -25,7 +25,6 @@ createInertiaApp({
|
||||
layout: (name) => {
|
||||
switch (true) {
|
||||
case name === 'admin/manage/cutting/show':
|
||||
case name === 'admin/manage/invoice/print':
|
||||
return null;
|
||||
case name === 'welcome':
|
||||
return null;
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
HandCoins,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Scissors,
|
||||
Settings,
|
||||
@ -49,7 +48,6 @@ import { index as attendancesIndex } from '@/routes/admin/hr/attendances';
|
||||
import { index as employeesIndex } from '@/routes/admin/hr/employees';
|
||||
import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
|
||||
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
|
||||
import { index as invoicesIndex } from '@/routes/admin/manage/invoices';
|
||||
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
|
||||
import { index as stokOpnamesIndex } from '@/routes/admin/manage/stok-opnames';
|
||||
@ -90,7 +88,6 @@ const kelolaItems: NavMenuItem[] = [
|
||||
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.view' },
|
||||
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.view' },
|
||||
{ title: 'Stok Opname', href: stokOpnamesIndex.url(), icon: ClipboardCheck, permission: 'stok_opnames.view' },
|
||||
{ title: 'Invoice', href: invoicesIndex.url(), icon: Receipt, permission: 'invoices.view' },
|
||||
];
|
||||
|
||||
const keuanganItems: NavMenuItem[] = [
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Printer, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/data-display';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { edit, print } from '@/routes/admin/manage/invoices';
|
||||
import { InvoiceShareButton } from './invoice-share-button';
|
||||
|
||||
export type Invoice = {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_name: string;
|
||||
amount: number;
|
||||
formatted_amount: string;
|
||||
date: string;
|
||||
formatted_date: string;
|
||||
due_date: string | null;
|
||||
formatted_due_date: string | null;
|
||||
status: 'unpaid' | 'paid';
|
||||
status_label: string;
|
||||
description: string | null;
|
||||
items_count: number;
|
||||
};
|
||||
|
||||
export type InvoiceItem = {
|
||||
id?: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
};
|
||||
|
||||
export type InvoiceForEdit = {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_name: string;
|
||||
customer_phone: string | null;
|
||||
customer_address: string | null;
|
||||
date: string;
|
||||
due_date: string | null;
|
||||
status: 'unpaid' | 'paid';
|
||||
description: string | null;
|
||||
items: InvoiceItem[];
|
||||
};
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
unpaid: {
|
||||
label: 'Belum Lunas',
|
||||
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
|
||||
},
|
||||
paid: {
|
||||
label: 'Lunas',
|
||||
className: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] ?? statusConfig.unpaid;
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleDeleteClick: (invoice: Invoice) => void;
|
||||
can: (permission: string) => boolean;
|
||||
};
|
||||
|
||||
export function createInvoiceColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Invoice>[] {
|
||||
const { handleDeleteClick, can } = params;
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = [
|
||||
{
|
||||
accessorKey: 'invoice_number',
|
||||
header: () => <span>Nomor Invoice</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{row.getValue('invoice_number') as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'customer_name',
|
||||
header: () => <span>Pelanggan</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.getValue('customer_name') as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'items_count',
|
||||
header: () => <span>Item</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.getValue('items_count') as number}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'formatted_amount',
|
||||
header: () => <span>Jumlah</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.getValue('formatted_amount') as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'formatted_date',
|
||||
header: () => <span>Tanggal</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.getValue('formatted_date') as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'formatted_due_date',
|
||||
header: () => <span>Jatuh Tempo</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{(row.getValue('formatted_due_date') as string) ?? '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{getStatusBadge(row.getValue('status') as string)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[170px] text-center',
|
||||
headerClassName: 'w-[170px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<InvoiceShareButton invoice={invoice} />
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Cetak PDF',
|
||||
icon: <Printer className="h-4 w-4" />,
|
||||
href: print.url(invoice.id),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
show: can('invoices.update'),
|
||||
href: edit.url(invoice.id),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
show: can('invoices.delete'),
|
||||
onClick: () => handleDeleteClick(invoice),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return columns;
|
||||
}
|
||||
@ -1,512 +0,0 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDialog } from '@/components/dialogs';
|
||||
import { DatePicker, PhoneNumberInput } from '@/components/inputs';
|
||||
import { RupiahInput } from '@/components/inputs';
|
||||
import { InputError } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { index as invoiceIndex, store } from '@/routes/admin/manage/invoices';
|
||||
|
||||
type ItemRow = {
|
||||
name: string;
|
||||
quantity: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
function toDateInputValue(date: Date | undefined) {
|
||||
return date ? date.toISOString().split('T')[0] : '';
|
||||
}
|
||||
|
||||
function emptyItem(): ItemRow {
|
||||
return { name: '', quantity: '1', price: 0 };
|
||||
}
|
||||
|
||||
type Props = {
|
||||
statusOptions: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
export default function InvoiceCreate({ statusOptions }: Props) {
|
||||
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
||||
const [status, setStatus] = useState('unpaid');
|
||||
const [description, setDescription] = useState('');
|
||||
const [items, setItems] = useState<ItemRow[]>([emptyItem()]);
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteItemIndex, setDeleteItemIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
setItems((prev) => [...prev, emptyItem()]);
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const confirmRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
if (items.length <= 1) {
|
||||
removeItem(index);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteItemIndex(index);
|
||||
setDeleteConfirmOpen(true);
|
||||
},
|
||||
[items.length, removeItem],
|
||||
);
|
||||
|
||||
const updateItem = useCallback(
|
||||
(index: number, field: keyof ItemRow, value: string | number) => {
|
||||
setItems((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const total = items.reduce(
|
||||
(sum, item) => sum + (parseInt(item.quantity, 10) || 0) * item.price,
|
||||
0,
|
||||
);
|
||||
|
||||
function getPayload() {
|
||||
return {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_name: customerName,
|
||||
date: toDateInputValue(date),
|
||||
due_date: dueDate ? toDateInputValue(dueDate) : null,
|
||||
status,
|
||||
description: description || null,
|
||||
items: items.map((item) => ({
|
||||
name: item.name,
|
||||
quantity: parseInt(item.quantity, 10) || 0,
|
||||
price: item.price,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Tambah Invoice" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Tambah Invoice
|
||||
</h2>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={invoiceIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action={store()}
|
||||
transform={(data) => ({
|
||||
...data,
|
||||
...getPayload(),
|
||||
})}
|
||||
onError={() => {
|
||||
toast.error(
|
||||
'Ada data yang belum sesuai, silakan periksa kembali input Anda.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Invoice</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invoice_number">
|
||||
Nomor Invoice{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="invoice_number"
|
||||
value={invoiceNumber}
|
||||
onChange={(e) =>
|
||||
setInvoiceNumber(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Masukkan nomor invoice"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.invoice_number}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer_name">
|
||||
Nama Pelanggan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="customer_name"
|
||||
value={customerName}
|
||||
onChange={(e) =>
|
||||
setCustomerName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Masukkan nama pelanggan"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.customer_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer_phone">
|
||||
No. Telepon Pelanggan
|
||||
</Label>
|
||||
<PhoneNumberInput
|
||||
name="customer_phone"
|
||||
placeholder="0812 3456 7890"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.customer_phone
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-3">
|
||||
<Label htmlFor="customer_address">
|
||||
Alamat Pelanggan
|
||||
</Label>
|
||||
<Textarea
|
||||
id="customer_address"
|
||||
name="customer_address"
|
||||
placeholder="Masukkan alamat pelanggan"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.customer_address
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:col-span-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">
|
||||
Tanggal{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder="Pilih tanggal"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="due_date">
|
||||
Jatuh Tempo
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.due_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Item Invoice</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Item {index + 1}
|
||||
</h4>
|
||||
{items.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
confirmRemoveItem(
|
||||
index,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2 md:col-span-1">
|
||||
<Label>
|
||||
Nama Item{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
'name',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
placeholder="Contoh: Jasa Cutting"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`items.${index}.name`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={
|
||||
item.quantity
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
'quantity',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`items.${index}.quantity`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Harga Satuan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
value={item.price}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateItem(
|
||||
index,
|
||||
'price',
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`items.${index}.price`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
Subtotal:{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(
|
||||
(parseInt(
|
||||
item.quantity,
|
||||
10,
|
||||
) || 0) *
|
||||
item.price,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Item
|
||||
</Button>
|
||||
<InputError message={errors.items} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 md:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ringkasan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Status{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={setStatus}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(
|
||||
(opt) => (
|
||||
<SelectItem
|
||||
key={
|
||||
opt.value
|
||||
}
|
||||
value={
|
||||
opt.value
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError
|
||||
message={errors.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) =>
|
||||
setDescription(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={
|
||||
processing ||
|
||||
!invoiceNumber ||
|
||||
!customerName ||
|
||||
!date ||
|
||||
items.length === 0 ||
|
||||
items.some((i) => !i.name)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteItemIndex(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Item"
|
||||
description="Apakah Anda yakin ingin menghapus item ini?"
|
||||
confirmLabel="Hapus"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
if (deleteItemIndex !== null) {
|
||||
removeItem(deleteItemIndex);
|
||||
}
|
||||
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteItemIndex(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,556 +0,0 @@
|
||||
import { ConfirmDialog } from '@/components/dialogs';
|
||||
import { DatePicker, PhoneNumberInput, RupiahInput } from '@/components/inputs';
|
||||
import { InputError } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
index as invoiceIndex,
|
||||
print,
|
||||
update,
|
||||
} from '@/routes/admin/manage/invoices';
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import { ArrowLeft, Plus, Printer, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { InvoiceForEdit } from './columns';
|
||||
|
||||
type ItemRow = {
|
||||
name: string;
|
||||
quantity: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
invoice: InvoiceForEdit;
|
||||
statusOptions: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
function toDateInputValue(date: Date | undefined) {
|
||||
return date ? date.toISOString().split('T')[0] : '';
|
||||
}
|
||||
|
||||
function parseDateValue(value: string | null) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
function emptyItem(): ItemRow {
|
||||
return { name: '', quantity: '1', price: 0 };
|
||||
}
|
||||
|
||||
export default function InvoiceEdit({ invoice, statusOptions }: Props) {
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(
|
||||
invoice.invoice_number,
|
||||
);
|
||||
const [customerName, setCustomerName] = useState(invoice.customer_name);
|
||||
const [date, setDate] = useState<Date | undefined>(
|
||||
parseDateValue(invoice.date),
|
||||
);
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(
|
||||
parseDateValue(invoice.due_date),
|
||||
);
|
||||
const [status, setStatus] = useState<string>(invoice.status);
|
||||
const [description, setDescription] = useState(
|
||||
invoice.description ?? '',
|
||||
);
|
||||
const [items, setItems] = useState<ItemRow[]>(() =>
|
||||
invoice.items.length > 0
|
||||
? invoice.items.map((item) => ({
|
||||
name: item.name,
|
||||
quantity: String(item.quantity),
|
||||
price: item.price,
|
||||
}))
|
||||
: [emptyItem()],
|
||||
);
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteItemIndex, setDeleteItemIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
setItems((prev) => [...prev, emptyItem()]);
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
setItems((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const confirmRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
if (items.length <= 1) {
|
||||
removeItem(index);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteItemIndex(index);
|
||||
setDeleteConfirmOpen(true);
|
||||
},
|
||||
[items.length, removeItem],
|
||||
);
|
||||
|
||||
const updateItem = useCallback(
|
||||
(index: number, field: keyof ItemRow, value: string | number) => {
|
||||
setItems((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const total = items.reduce(
|
||||
(sum, item) => sum + (parseInt(item.quantity, 10) || 0) * item.price,
|
||||
0,
|
||||
);
|
||||
|
||||
function getPayload() {
|
||||
return {
|
||||
invoice_number: invoiceNumber,
|
||||
customer_name: customerName,
|
||||
date: toDateInputValue(date),
|
||||
due_date: dueDate ? toDateInputValue(dueDate) : null,
|
||||
status,
|
||||
description: description || null,
|
||||
items: items.map((item) => ({
|
||||
name: item.name,
|
||||
quantity: parseInt(item.quantity, 10) || 0,
|
||||
price: item.price,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Edit Invoice" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Edit Invoice
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={print.url(invoice.id)}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Cetak PDF
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={invoiceIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
action={update(invoice.id)}
|
||||
transform={(data) => ({
|
||||
...data,
|
||||
...getPayload(),
|
||||
})}
|
||||
onError={() => {
|
||||
toast.error(
|
||||
'Ada data yang belum sesuai, silakan periksa kembali input Anda.',
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Invoice</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invoice_number">
|
||||
Nomor Invoice{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="invoice_number"
|
||||
value={invoiceNumber}
|
||||
onChange={(e) =>
|
||||
setInvoiceNumber(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Masukkan nomor invoice"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.invoice_number}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer_name">
|
||||
Nama Pelanggan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="customer_name"
|
||||
value={customerName}
|
||||
onChange={(e) =>
|
||||
setCustomerName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Masukkan nama pelanggan"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.customer_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="customer_phone">
|
||||
No. Telepon Pelanggan
|
||||
</Label>
|
||||
<PhoneNumberInput
|
||||
name="customer_phone"
|
||||
defaultValue={
|
||||
invoice.customer_phone
|
||||
}
|
||||
placeholder="0812 3456 7890"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.customer_phone
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-3">
|
||||
<Label htmlFor="customer_address">
|
||||
Alamat Pelanggan
|
||||
</Label>
|
||||
<Textarea
|
||||
id="customer_address"
|
||||
name="customer_address"
|
||||
defaultValue={
|
||||
invoice.customer_address ??
|
||||
''
|
||||
}
|
||||
placeholder="Masukkan alamat pelanggan"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.customer_address
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:col-span-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">
|
||||
Tanggal{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder="Pilih tanggal"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="due_date">
|
||||
Jatuh Tempo
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.due_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Item Invoice</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
Item {index + 1}
|
||||
</h4>
|
||||
{items.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
confirmRemoveItem(
|
||||
index,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2 md:col-span-1">
|
||||
<Label>
|
||||
Nama Item{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
'name',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
placeholder="Contoh: Jasa Cutting"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`items.${index}.name`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={
|
||||
item.quantity
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
index,
|
||||
'quantity',
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`items.${index}.quantity`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Harga Satuan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
value={item.price}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateItem(
|
||||
index,
|
||||
'price',
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`items.${index}.price`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
Subtotal:{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(
|
||||
(parseInt(
|
||||
item.quantity,
|
||||
10,
|
||||
) || 0) *
|
||||
item.price,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Item
|
||||
</Button>
|
||||
<InputError message={errors.items} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 md:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ringkasan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Status{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={setStatus}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(
|
||||
(opt) => (
|
||||
<SelectItem
|
||||
key={
|
||||
opt.value
|
||||
}
|
||||
value={
|
||||
opt.value
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError
|
||||
message={errors.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) =>
|
||||
setDescription(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={
|
||||
processing ||
|
||||
!invoiceNumber ||
|
||||
!customerName ||
|
||||
!date ||
|
||||
items.length === 0 ||
|
||||
items.some((i) => !i.name)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteItemIndex(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Item"
|
||||
description="Apakah Anda yakin ingin menghapus item ini?"
|
||||
confirmLabel="Hapus"
|
||||
variant="destructive"
|
||||
onConfirm={() => {
|
||||
if (deleteItemIndex !== null) {
|
||||
removeItem(deleteItemIndex);
|
||||
}
|
||||
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteItemIndex(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-display';
|
||||
import { DataTable } from '@/components/data-display';
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||
import { PageHeader } from '@/components/layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
create as invoiceCreate,
|
||||
destroy,
|
||||
index as invoiceIndex,
|
||||
} from '@/routes/admin/manage/invoices';
|
||||
import type { Invoice } from './columns';
|
||||
import { createInvoiceColumns } from './columns';
|
||||
|
||||
type Props = {
|
||||
invoices: {
|
||||
data: Invoice[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function InvoiceIndex({ invoices }: Props) {
|
||||
const { can } = useCan();
|
||||
const [deleting, setDeleting] = useState<Invoice | null>(null);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: invoices.current_page,
|
||||
last_page: invoices.last_page,
|
||||
per_page: invoices.per_page,
|
||||
total: invoices.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => invoiceIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createInvoiceColumns({
|
||||
handleDeleteClick: (invoice) => setDeleting(invoice),
|
||||
can,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Invoice" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Invoice"
|
||||
actions={
|
||||
can('invoices.create') ? (
|
||||
<Button asChild>
|
||||
<Link href={invoiceCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={invoices.data}
|
||||
searchKey="invoice_number"
|
||||
emptyText="Belum ada data invoice."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Invoice"
|
||||
description={(invoice) =>
|
||||
`Apakah Anda yakin ingin menghapus invoice "${invoice.invoice_number}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { share as invoiceShare } from '@/routes/admin/manage/invoices';
|
||||
|
||||
export type ShareableInvoice = {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_name: string;
|
||||
formatted_amount: string;
|
||||
formatted_date: string;
|
||||
formatted_due_date?: string | null;
|
||||
status_label: string;
|
||||
};
|
||||
|
||||
function generateShareLink(invoiceId: number): string {
|
||||
return window.location.origin + invoiceShare.url(invoiceId);
|
||||
}
|
||||
|
||||
function generateWhatsappText(invoice: ShareableInvoice): string {
|
||||
const shareLink = generateShareLink(invoice.id);
|
||||
|
||||
let text = `*Invoice ${invoice.invoice_number}*\n`;
|
||||
text += `Kepada: ${invoice.customer_name}\n`;
|
||||
text += `Tanggal: ${invoice.formatted_date}\n`;
|
||||
|
||||
if (invoice.formatted_due_date) {
|
||||
text += `Jatuh Tempo: ${invoice.formatted_due_date}\n`;
|
||||
}
|
||||
|
||||
text += `Status: ${invoice.status_label}\n`;
|
||||
text += `Total: ${invoice.formatted_amount}\n`;
|
||||
text += `\nLihat detail invoice:\n${shareLink}`;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function openWAWeb(text: string) {
|
||||
const encoded = encodeURIComponent(text);
|
||||
window.open(`https://wa.me/?text=${encoded}`, '_blank');
|
||||
}
|
||||
|
||||
function openWAAndroid(text: string, pkg: string) {
|
||||
const encoded = encodeURIComponent(text);
|
||||
window.open(
|
||||
`intent://send?text=${encoded}#Intent;scheme=whatsapp;package=${pkg};end`,
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
function shareViaWhatsAppMessenger(text: string) {
|
||||
if (/android/i.test(navigator.userAgent)) {
|
||||
openWAAndroid(text, 'com.whatsapp');
|
||||
} else {
|
||||
openWAWeb(text);
|
||||
}
|
||||
}
|
||||
|
||||
function shareViaWhatsAppBusiness(text: string) {
|
||||
if (/android/i.test(navigator.userAgent)) {
|
||||
openWAAndroid(text, 'com.whatsapp.w4b');
|
||||
} else {
|
||||
openWAWeb(text);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
invoice: ShareableInvoice;
|
||||
};
|
||||
|
||||
export function InvoiceShareButton({ invoice }: Props) {
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const shareText = generateWhatsappText(invoice);
|
||||
|
||||
function handleShareWAMessenger() {
|
||||
shareViaWhatsAppMessenger(shareText);
|
||||
setShareOpen(false);
|
||||
}
|
||||
|
||||
function handleShareWABusiness() {
|
||||
shareViaWhatsAppBusiness(shareText);
|
||||
setShareOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Popover open={shareOpen} onOpenChange={setShareOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Bagikan ke WhatsApp</TooltipContent>
|
||||
<PopoverContent align="end" className="w-48 space-y-1 p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleShareWAMessenger}
|
||||
>
|
||||
WhatsApp Messenger
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleShareWABusiness}
|
||||
>
|
||||
WhatsApp Business
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@ -1,221 +0,0 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { ArrowLeft, Printer } from 'lucide-react';
|
||||
import { AppLogoIcon } from '@/components/brand';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { index as invoiceIndex } from '@/routes/admin/manage/invoices';
|
||||
import { InvoiceShareButton } from './invoice-share-button';
|
||||
|
||||
type InvoiceItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
subtotal: number;
|
||||
};
|
||||
|
||||
type Company = {
|
||||
name: string;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
};
|
||||
|
||||
type PrintInvoice = {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_name: string;
|
||||
customer_phone: string | null;
|
||||
customer_address: string | null;
|
||||
amount: number;
|
||||
formatted_amount: string;
|
||||
formatted_date: string;
|
||||
formatted_due_date: string | null;
|
||||
status: 'unpaid' | 'paid';
|
||||
status_label: string;
|
||||
description: string | null;
|
||||
items: InvoiceItem[];
|
||||
company: Company;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
invoice: PrintInvoice;
|
||||
};
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
unpaid: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
|
||||
paid: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
};
|
||||
|
||||
export default function InvoicePrint({ invoice }: Props) {
|
||||
const { company } = invoice;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={`Invoice ${invoice.invoice_number}`} />
|
||||
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex items-center justify-between print:hidden">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={invoiceIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<InvoiceShareButton invoice={invoice} />
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Cetak / Simpan PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-lg border bg-card shadow-sm print:rounded-none print:border-none print:shadow-none">
|
||||
<AppLogoIcon className="pointer-events-none absolute inset-0 m-auto h-80 w-80 object-contain opacity-20 print:opacity-25" />
|
||||
|
||||
<div className="relative z-10 border-b bg-muted/30 p-6 print:bg-white">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
INVOICE
|
||||
</h1>
|
||||
<p className="mt-1 font-medium">
|
||||
{company.name}
|
||||
</p>
|
||||
{company.address && (
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
{company.address}
|
||||
</p>
|
||||
)}
|
||||
{(company.phone || company.email) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{[company.phone, company.email]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">
|
||||
{invoice.invoice_number}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
STATUS_BADGE_CLASSES[invoice.status]
|
||||
}
|
||||
>
|
||||
{invoice.status_label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid grid-cols-1 gap-6 p-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ditagihkan Kepada
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{invoice.customer_name}
|
||||
</p>
|
||||
{invoice.customer_phone && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invoice.customer_phone}
|
||||
</p>
|
||||
)}
|
||||
{invoice.customer_address && (
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
{invoice.customer_address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tanggal Invoice
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{invoice.formatted_date}
|
||||
</p>
|
||||
{invoice.formatted_due_date && (
|
||||
<>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Jatuh Tempo
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{invoice.formatted_due_date}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 px-6 pb-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead className="text-center">
|
||||
Jumlah
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Harga Satuan
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Subtotal
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoice.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.subtotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<div className="flex items-center justify-between border-t pt-2 text-base font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{invoice.formatted_amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoice.description && (
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keterangan
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{invoice.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Http\Controllers\Admin\HR\EmployeeController;
|
||||
use App\Http\Controllers\Admin\HR\LeaveRequestController;
|
||||
use App\Http\Controllers\Admin\Manage\CuttingController;
|
||||
use App\Http\Controllers\Admin\Manage\InvoiceController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||
use App\Http\Controllers\Admin\Manage\RestockController;
|
||||
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
||||
@ -34,7 +33,6 @@
|
||||
Route::get('/', HomepageController::class)->name('home');
|
||||
|
||||
Route::get('admin/manage/cuttings/{cutting}/share', [CuttingController::class, 'share'])->name('admin.manage.cuttings.share');
|
||||
Route::get('admin/manage/invoices/{invoice}/share', [InvoiceController::class, 'share'])->name('admin.manage.invoices.share');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::prefix('admin')->group(function () {
|
||||
@ -93,9 +91,6 @@
|
||||
Route::patch('stok-opnames/{stokOpname}/verify', [StokOpnameController::class, 'verify'])->name('stok-opnames.verify')->middleware('permission:stok_opnames.verify');
|
||||
Route::patch('stok-opnames/{stokOpname}/reject', [StokOpnameController::class, 'reject'])->name('stok-opnames.reject')->middleware('permission:stok_opnames.verify');
|
||||
Route::patch('stok-opnames/{stokOpname}/cancel', [StokOpnameController::class, 'cancel'])->name('stok-opnames.cancel')->middleware('permission:stok_opnames.update');
|
||||
|
||||
Route::resource('invoices', InvoiceController::class)->except(['show'])->middleware('permission:invoices.view|invoices.create|invoices.update|invoices.delete');
|
||||
Route::get('invoices/{invoice}/print', [InvoiceController::class, 'print'])->name('invoices.print')->middleware('permission:invoices.view');
|
||||
});
|
||||
|
||||
Route::prefix('finance')->name('admin.finance.')->group(function () {
|
||||
|
||||
@ -1,574 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function giveInvoicePermissions(): User
|
||||
{
|
||||
$seeder = new RolePermissionSeeder;
|
||||
$seeder->run();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->givePermissionTo([
|
||||
'invoices.view',
|
||||
'invoices.create',
|
||||
'invoices.update',
|
||||
'invoices.delete',
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| AUTHENTICATION & AUTHORIZATION
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the invoice index page', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated users can visit the invoice create page', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.create'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated users can visit the invoice edit page', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$invoice = Invoice::factory()->create();
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.edit', $invoice));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated users can visit the invoice print page', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$invoice = Invoice::factory()->create();
|
||||
$invoice->items()->create(['name' => 'Item', 'quantity' => 2, 'price' => 25000, 'subtotal' => 50000]);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.print', $invoice));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/print')
|
||||
->where('invoice.invoice_number', $invoice->invoice_number)
|
||||
->has('invoice.items', 1)
|
||||
->has('invoice.company')
|
||||
);
|
||||
});
|
||||
|
||||
test('anyone can visit the public invoice share page without logging in', function () {
|
||||
$invoice = Invoice::factory()->create();
|
||||
$invoice->items()->create(['name' => 'Item', 'quantity' => 1, 'price' => 25000, 'subtotal' => 25000]);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.share', $invoice));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/print')
|
||||
->where('invoice.invoice_number', $invoice->invoice_number)
|
||||
);
|
||||
});
|
||||
|
||||
test('users without invoices.view permission cannot visit the index page', function () {
|
||||
(new RolePermissionSeeder)->run();
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('developer role has invoices permissions by default', function () {
|
||||
(new RolePermissionSeeder)->run();
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('developer');
|
||||
|
||||
expect($user->can('invoices.view'))->toBeTrue();
|
||||
expect($user->can('invoices.create'))->toBeTrue();
|
||||
expect($user->can('invoices.update'))->toBeTrue();
|
||||
expect($user->can('invoices.delete'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('owner role has invoices permissions by default', function () {
|
||||
(new RolePermissionSeeder)->run();
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('owner');
|
||||
|
||||
expect($user->can('invoices.view'))->toBeTrue();
|
||||
expect($user->can('invoices.create'))->toBeTrue();
|
||||
expect($user->can('invoices.update'))->toBeTrue();
|
||||
expect($user->can('invoices.delete'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('admin-toko role does not have invoices permissions', function () {
|
||||
(new RolePermissionSeeder)->run();
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('admin-toko');
|
||||
|
||||
expect($user->can('invoices.view'))->toBeFalse();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| INDEX PAGE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('invoice index page displays invoices', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
Invoice::factory()->count(3)->create();
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/index')
|
||||
->has('invoices.data', 3)
|
||||
->where('invoices.total', 3)
|
||||
->where('invoices.current_page', 1)
|
||||
->where('invoices.per_page', 25)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page works with zero invoices', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/index')
|
||||
->has('invoices.data', 0)
|
||||
->where('invoices.total', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('index page does not display soft-deleted invoices', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
Invoice::factory()->create(['invoice_number' => 'INV-000001']);
|
||||
Invoice::factory()->create(['invoice_number' => 'INV-000002'])->delete();
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/index')
|
||||
->has('invoices.data', 1)
|
||||
->where('invoices.data.0.invoice_number', 'INV-000001')
|
||||
);
|
||||
});
|
||||
|
||||
test('index page can search by invoice number', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
Invoice::factory()->create(['invoice_number' => 'INV-ALPHA']);
|
||||
Invoice::factory()->create(['invoice_number' => 'INV-BETA']);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index', ['search' => 'ALPHA']));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/index')
|
||||
->has('invoices.data', 1)
|
||||
->where('invoices.data.0.invoice_number', 'INV-ALPHA')
|
||||
);
|
||||
});
|
||||
|
||||
test('index page can search by customer name', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
Invoice::factory()->create(['customer_name' => 'Budi Santoso']);
|
||||
Invoice::factory()->create(['customer_name' => 'Siti Aminah']);
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index', ['search' => 'Budi']));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/index')
|
||||
->has('invoices.data', 1)
|
||||
->where('invoices.data.0.customer_name', 'Budi Santoso')
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CREATE / STORE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('invoice can be created with items', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000123',
|
||||
'customer_name' => 'Budi Santoso',
|
||||
'customer_phone' => '081234567890',
|
||||
'customer_address' => 'Jl. Merdeka No. 1',
|
||||
'date' => '2026-08-22',
|
||||
'due_date' => '2026-09-05',
|
||||
'status' => 'unpaid',
|
||||
'description' => 'Pembayaran jasa cutting',
|
||||
'items' => [
|
||||
['name' => 'Jasa Cutting', 'quantity' => 2, 'price' => 50000],
|
||||
['name' => 'Jasa Jahit', 'quantity' => 1, 'price' => 50000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('invoices', [
|
||||
'invoice_number' => 'INV-000123',
|
||||
'customer_name' => 'Budi Santoso',
|
||||
'customer_phone' => '081234567890',
|
||||
'customer_address' => 'Jl. Merdeka No. 1',
|
||||
'amount' => 150000,
|
||||
'status' => 'unpaid',
|
||||
]);
|
||||
|
||||
$invoice = Invoice::where('invoice_number', 'INV-000123')->first();
|
||||
$this->assertDatabaseHas('invoice_items', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'name' => 'Jasa Cutting',
|
||||
'quantity' => 2,
|
||||
'price' => 50000,
|
||||
'subtotal' => 100000,
|
||||
]);
|
||||
$this->assertDatabaseHas('invoice_items', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'name' => 'Jasa Jahit',
|
||||
'quantity' => 1,
|
||||
'price' => 50000,
|
||||
'subtotal' => 50000,
|
||||
]);
|
||||
expect($invoice->items()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('invoice can be created without optional fields', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000124',
|
||||
'customer_name' => 'Minimal Customer',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item Tunggal', 'quantity' => 1, 'price' => 50000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('invoices', [
|
||||
'invoice_number' => 'INV-000124',
|
||||
'amount' => 50000,
|
||||
'due_date' => null,
|
||||
'description' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('invoice_number is required', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => '',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('invoice_number');
|
||||
});
|
||||
|
||||
test('invoice_number must be unique', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
Invoice::factory()->create(['invoice_number' => 'INV-DUPLICATE']);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-DUPLICATE',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('invoice_number');
|
||||
});
|
||||
|
||||
test('customer_name is required', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000125',
|
||||
'customer_name' => '',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('customer_name');
|
||||
});
|
||||
|
||||
test('items is required', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000126',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('items');
|
||||
});
|
||||
|
||||
test('item name is required', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000127',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => '', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('items.0.name');
|
||||
});
|
||||
|
||||
test('item quantity must be at least 1', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000128',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 0, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('items.0.quantity');
|
||||
});
|
||||
|
||||
test('item price must be an integer', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000129',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 'not-a-number'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('items.0.price');
|
||||
});
|
||||
|
||||
test('date is required', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000130',
|
||||
'customer_name' => 'Test',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('date');
|
||||
});
|
||||
|
||||
test('due_date must be on or after date', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000131',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'due_date' => '2026-08-01',
|
||||
'status' => 'unpaid',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('due_date');
|
||||
});
|
||||
|
||||
test('status must be a valid enum value', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.manage.invoices.store'), [
|
||||
'invoice_number' => 'INV-000132',
|
||||
'customer_name' => 'Test',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'cancelled',
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 1000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('status');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| UPDATE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('invoice can be updated with new items', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$invoice = Invoice::factory()->create([
|
||||
'invoice_number' => 'INV-000200',
|
||||
'status' => 'unpaid',
|
||||
]);
|
||||
$invoice->items()->create(['name' => 'Old Item', 'quantity' => 1, 'price' => 10000, 'subtotal' => 10000]);
|
||||
|
||||
$response = $this->put(route('admin.manage.invoices.update', $invoice), [
|
||||
'invoice_number' => 'INV-000200',
|
||||
'customer_name' => 'Updated Customer',
|
||||
'date' => '2026-08-22',
|
||||
'status' => 'paid',
|
||||
'items' => [
|
||||
['name' => 'New Item', 'quantity' => 2, 'price' => 100000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('invoices', [
|
||||
'id' => $invoice->id,
|
||||
'customer_name' => 'Updated Customer',
|
||||
'amount' => 200000,
|
||||
'status' => 'paid',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('invoice_items', ['name' => 'Old Item']);
|
||||
$this->assertDatabaseHas('invoice_items', [
|
||||
'invoice_id' => $invoice->id,
|
||||
'name' => 'New Item',
|
||||
'quantity' => 2,
|
||||
'price' => 100000,
|
||||
'subtotal' => 200000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('invoice_number unique rule ignores current invoice on update', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$invoice = Invoice::factory()->create(['invoice_number' => 'INV-000201']);
|
||||
$invoice->items()->create(['name' => 'Item', 'quantity' => 1, 'price' => 10000, 'subtotal' => 10000]);
|
||||
|
||||
$response = $this->put(route('admin.manage.invoices.update', $invoice), [
|
||||
'invoice_number' => 'INV-000201',
|
||||
'customer_name' => $invoice->customer_name,
|
||||
'date' => $invoice->date->format('Y-m-d'),
|
||||
'status' => $invoice->status,
|
||||
'items' => [
|
||||
['name' => 'Item', 'quantity' => 1, 'price' => 10000],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| DELETE
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('invoice can be deleted', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
$invoice = Invoice::factory()->create();
|
||||
$invoice->items()->create(['name' => 'Item', 'quantity' => 1, 'price' => 10000, 'subtotal' => 10000]);
|
||||
|
||||
$response = $this->delete(route('admin.manage.invoices.destroy', $invoice));
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSoftDeleted('invoices', ['id' => $invoice->id]);
|
||||
$this->assertDatabaseMissing('invoice_items', ['invoice_id' => $invoice->id]);
|
||||
});
|
||||
|
||||
test('index page displays correct count after delete', function () {
|
||||
$user = giveInvoicePermissions();
|
||||
$this->actingAs($user);
|
||||
|
||||
Invoice::factory()->count(5)->create();
|
||||
$invoice = Invoice::first();
|
||||
|
||||
$this->delete(route('admin.manage.invoices.destroy', $invoice));
|
||||
|
||||
$response = $this->get(route('admin.manage.invoices.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/manage/invoice/index')
|
||||
->has('invoices.data', 4)
|
||||
->where('invoices.total', 4)
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user