diff --git a/app/Enums/InvoiceStatus.php b/app/Enums/InvoiceStatus.php new file mode 100644 index 0000000..83acdc3 --- /dev/null +++ b/app/Enums/InvoiceStatus.php @@ -0,0 +1,21 @@ + 'Belum Lunas', + self::PAID => 'Lunas', + }; + } +} diff --git a/app/Http/Controllers/Admin/Manage/InvoiceController.php b/app/Http/Controllers/Admin/Manage/InvoiceController.php new file mode 100644 index 0000000..a23be39 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/InvoiceController.php @@ -0,0 +1,86 @@ + $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' + ); + } +} diff --git a/app/Http/Requests/Admin/Manage/InvoiceRequest.php b/app/Http/Requests/Admin/Manage/InvoiceRequest.php new file mode 100644 index 0000000..4166522 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/InvoiceRequest.php @@ -0,0 +1,54 @@ +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', + ]; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php new file mode 100644 index 0000000..75d7039 --- /dev/null +++ b/app/Models/Invoice.php @@ -0,0 +1,62 @@ + '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); + } +} diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php new file mode 100644 index 0000000..77a35d5 --- /dev/null +++ b/app/Models/InvoiceItem.php @@ -0,0 +1,25 @@ + 'integer', + 'price' => 'integer', + 'subtotal' => 'integer', + ]; + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } +} diff --git a/app/Services/Admin/Manage/InvoiceService.php b/app/Services/Admin/Manage/InvoiceService.php new file mode 100644 index 0000000..a93dd2c --- /dev/null +++ b/app/Services/Admin/Manage/InvoiceService.php @@ -0,0 +1,201 @@ +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(); + } +} diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php new file mode 100644 index 0000000..639395c --- /dev/null +++ b/database/factories/InvoiceFactory.php @@ -0,0 +1,21 @@ + '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(), + ]; + } +} diff --git a/database/migrations/2026_08_22_120000_create_invoices_table.php b/database/migrations/2026_08_22_120000_create_invoices_table.php new file mode 100644 index 0000000..1d6e36e --- /dev/null +++ b/database/migrations/2026_08_22_120000_create_invoices_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_22_121500_create_invoice_items_table.php b/database/migrations/2026_08_22_121500_create_invoice_items_table.php new file mode 100644 index 0000000..df689a2 --- /dev/null +++ b/database/migrations/2026_08_22_121500_create_invoice_items_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 2f71966..ef20e17 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -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'], ]; diff --git a/resources/js/app.tsx b/resources/js/app.tsx index d6209a3..fed6e6a 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -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; diff --git a/resources/js/components/layout/app-sidebar.tsx b/resources/js/components/layout/app-sidebar.tsx index 1f9fcad..19c4fed 100644 --- a/resources/js/components/layout/app-sidebar.tsx +++ b/resources/js/components/layout/app-sidebar.tsx @@ -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[] = [ diff --git a/resources/js/pages/admin/manage/invoice/columns.tsx b/resources/js/pages/admin/manage/invoice/columns.tsx new file mode 100644 index 0000000..5325384 --- /dev/null +++ b/resources/js/pages/admin/manage/invoice/columns.tsx @@ -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 = { + 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 ( + + {config.label} + + ); +} + +type CreateColumnsParams = { + handleDeleteClick: (invoice: Invoice) => void; + can: (permission: string) => boolean; +}; + +export function createInvoiceColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleDeleteClick, can } = params; + + const columns: ColumnDef[] = [ + { + accessorKey: 'invoice_number', + header: () => Nomor Invoice, + cell: ({ row }) => ( + + {row.getValue('invoice_number') as string} + + ), + }, + { + accessorKey: 'customer_name', + header: () => Pelanggan, + cell: ({ row }) => ( + {row.getValue('customer_name') as string} + ), + }, + { + accessorKey: 'items_count', + header: () => Item, + cell: ({ row }) => ( + {row.getValue('items_count') as number} + ), + }, + { + accessorKey: 'formatted_amount', + header: () => Jumlah, + cell: ({ row }) => ( + {row.getValue('formatted_amount') as string} + ), + }, + { + accessorKey: 'formatted_date', + header: () => Tanggal, + cell: ({ row }) => ( + {row.getValue('formatted_date') as string} + ), + }, + { + accessorKey: 'formatted_due_date', + header: () => Jatuh Tempo, + cell: ({ row }) => ( + + {(row.getValue('formatted_due_date') as string) ?? '-'} + + ), + }, + { + accessorKey: 'status', + header: () => Status, + cell: ({ row }) => ( + {getStatusBadge(row.getValue('status') as string)} + ), + }, + ]; + + columns.push({ + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[170px] text-center', + headerClassName: 'w-[170px] text-center', + }, + cell: ({ row }) => { + const invoice = row.original; + + return ( +
+ + , + href: print.url(invoice.id), + }, + { + label: 'Edit', + icon: , + show: can('invoices.update'), + href: edit.url(invoice.id), + }, + { + label: 'Hapus', + icon: ( + + ), + show: can('invoices.delete'), + onClick: () => handleDeleteClick(invoice), + }, + ]} + /> +
+ ); + }, + }); + + return columns; +} diff --git a/resources/js/pages/admin/manage/invoice/create.tsx b/resources/js/pages/admin/manage/invoice/create.tsx new file mode 100644 index 0000000..aff9c92 --- /dev/null +++ b/resources/js/pages/admin/manage/invoice/create.tsx @@ -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(new Date()); + const [dueDate, setDueDate] = useState(undefined); + const [status, setStatus] = useState('unpaid'); + const [description, setDescription] = useState(''); + const [items, setItems] = useState([emptyItem()]); + + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteItemIndex, setDeleteItemIndex] = useState( + 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)[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 ( + <> + + +
+
+

+ Tambah Invoice +

+ +
+ +
({ + ...data, + ...getPayload(), + })} + onError={() => { + toast.error( + 'Ada data yang belum sesuai, silakan periksa kembali input Anda.', + ); + }} + > + {({ errors, processing }) => ( +
+
+ + + Informasi Invoice + + +
+ + + setInvoiceNumber( + e.target.value, + ) + } + placeholder="Masukkan nomor invoice" + /> + +
+
+ + + setCustomerName( + e.target.value, + ) + } + placeholder="Masukkan nama pelanggan" + /> + +
+
+ + + +
+
+ +