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:
Yoga Pangestu 2026-08-22 15:13:28 +07:00
parent f562f2babe
commit 2a0b99c599
20 changed files with 2830 additions and 0 deletions

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

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

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