Compare commits

..

3 Commits

Author SHA1 Message Date
Yoga Pangestu
2a0b99c599 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.
2026-08-22 15:13:28 +07:00
Yoga Pangestu
f562f2babe Refactor SupplierTest to use permission setup function and enhance phone number validation tests
- Introduced `giveSupplierPermissions` function to streamline user permission setup for supplier tests.
- Updated all supplier-related tests to utilize the new permission setup function.
- Enhanced phone number validation tests to accept various formats and special characters.
- Removed redundant tests related to unsupported phone number formats.
- Consolidated tests for supplier creation and updates to improve readability and maintainability.
2026-08-22 13:46:22 +07:00
Yoga Pangestu
7aa802445a feat: add testing environment configuration and update phpunit settings for MySQL 2026-08-20 22:25:11 +07:00
27 changed files with 3502 additions and 1120 deletions

23
.env.testing Normal file
View File

@ -0,0 +1,23 @@
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

View 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',
};
}
}

View 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'
);
}
}

View 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',
];
}
}

View File

@ -17,7 +17,11 @@ public function handle(MessageLogged $event): void
return;
}
$developers = User::role('Developer')->where('is_active', true)->get();
try {
$developers = User::role('Developer')->where('is_active', true)->get();
} catch (\Throwable) {
return;
}
if ($developers->isEmpty()) {
return;

62
app/Models/Invoice.php Normal file
View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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(),
];
}
}

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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'],
];

View File

@ -17,14 +17,15 @@
<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="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="pangestu_local_dst_store"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
@ -33,4 +34,5 @@
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

View File

@ -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;

View File

@ -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[] = [

View 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;
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@ -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 () {

View 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)
);
});

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