feat: implement invoice management features including index, create, update, delete, and print functionalities
- Added InvoiceIndex component for displaying a list of invoices with pagination and search capabilities. - Created InvoiceShareButton component for sharing invoice details via WhatsApp. - Developed InvoicePrint component for printing invoice details. - Defined routes for invoice management including share and print functionalities. - Implemented InvoiceTest to cover authentication, authorization, and CRUD operations for invoices.
This commit is contained in:
parent
f562f2babe
commit
2a0b99c599
21
app/Enums/InvoiceStatus.php
Normal file
21
app/Enums/InvoiceStatus.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Admin/Manage/InvoiceController.php
Normal file
86
app/Http/Controllers/Admin/Manage/InvoiceController.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?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'
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Http/Requests/Admin/Manage/InvoiceRequest.php
Normal file
54
app/Http/Requests/Admin/Manage/InvoiceRequest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Models/Invoice.php
Normal file
62
app/Models/Invoice.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
25
app/Models/InvoiceItem.php
Normal file
25
app/Models/InvoiceItem.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
201
app/Services/Admin/Manage/InvoiceService.php
Normal file
201
app/Services/Admin/Manage/InvoiceService.php
Normal file
@ -0,0 +1,201 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
21
database/factories/InvoiceFactory.php
Normal file
21
database/factories/InvoiceFactory.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
<?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,6 +35,7 @@ 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'],
|
||||
];
|
||||
|
||||
@ -25,6 +25,7 @@ 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,6 +12,7 @@ import {
|
||||
HandCoins,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Scissors,
|
||||
Settings,
|
||||
@ -48,6 +49,7 @@ 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';
|
||||
@ -88,6 +90,7 @@ 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[] = [
|
||||
|
||||
173
resources/js/pages/admin/manage/invoice/columns.tsx
Normal file
173
resources/js/pages/admin/manage/invoice/columns.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
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;
|
||||
}
|
||||
512
resources/js/pages/admin/manage/invoice/create.tsx
Normal file
512
resources/js/pages/admin/manage/invoice/create.tsx
Normal file
@ -0,0 +1,512 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
556
resources/js/pages/admin/manage/invoice/edit.tsx
Normal file
556
resources/js/pages/admin/manage/invoice/edit.tsx
Normal file
@ -0,0 +1,556 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
resources/js/pages/admin/manage/invoice/index.tsx
Normal file
112
resources/js/pages/admin/manage/invoice/index.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
127
resources/js/pages/admin/manage/invoice/invoice-share-button.tsx
Normal file
127
resources/js/pages/admin/manage/invoice/invoice-share-button.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
221
resources/js/pages/admin/manage/invoice/print.tsx
Normal file
221
resources/js/pages/admin/manage/invoice/print.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
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,6 +11,7 @@
|
||||
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;
|
||||
@ -33,6 +34,7 @@
|
||||
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 () {
|
||||
@ -91,6 +93,9 @@
|
||||
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 () {
|
||||
|
||||
574
tests/Feature/Admin/Manage/InvoiceTest.php
Normal file
574
tests/Feature/Admin/Manage/InvoiceTest.php
Normal file
@ -0,0 +1,574 @@
|
||||
<?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)
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user