diff --git a/Modules/Branch/Database/Migrations/2024_03_21_create_branch_table.php b/Modules/Branch/Database/Migrations/2024_03_21_create_branch_table.php new file mode 100644 index 00000000..0a6b340f --- /dev/null +++ b/Modules/Branch/Database/Migrations/2024_03_21_create_branch_table.php @@ -0,0 +1,98 @@ +id(); + $table->string('name'); + $table->string('city')->nullable(); + $table->string('address')->nullable(); + $table->string('phone')->nullable(); + $table->string('email')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + // Insert default branch + DB::table('branches')->insert([ + 'name' => 'Toko Al Fatih Pusar', + 'city' => 'Lumajang', + 'address' => 'Toko Pusat', + 'phone' => '1234567890', + 'email' => 'alfatih@test.com', + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now() + ]); + } + + // Add branch_id to all necessary tables + $tables = [ + 'sales', + 'purchases', + 'purchase_returns', + 'expenses', + 'sale_payments', + 'purchase_payments', + 'purchase_return_payments' + ]; + + foreach ($tables as $tableName) { + if (Schema::hasTable($tableName)) { + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + if (!Schema::hasColumn($tableName, 'branch_id')) { + $table->unsignedBigInteger('branch_id')->default(1)->after('id'); + $table->foreign('branch_id')->references('id')->on('branches'); + } + }); + } + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Remove branch_id from all tables + $tables = [ + 'sales', + 'purchases', + 'purchase_returns', + 'expenses', + 'sale_payments', + 'purchase_payments', + 'purchase_return_payments' + ]; + + foreach ($tables as $tableName) { + if (Schema::hasTable($tableName)) { + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + if (Schema::hasColumn($tableName, 'branch_id')) { + $table->dropForeign(['branch_id']); + $table->dropColumn('branch_id'); + } + }); + } + } + + // Drop branches table + Schema::dropIfExists('branches'); + } +} \ No newline at end of file diff --git a/Modules/Branch/Resources/views/create.blade.php b/Modules/Branch/Resources/views/create.blade.php index 3a37dc30..0b3793b6 100644 --- a/Modules/Branch/Resources/views/create.blade.php +++ b/Modules/Branch/Resources/views/create.blade.php @@ -1,4 +1,4 @@ -@extends('core::layouts.master') +@extends('layouts.app') @section('title') Create Branch diff --git a/Modules/Branch/Resources/views/edit.blade.php b/Modules/Branch/Resources/views/edit.blade.php index 17133eff..242672be 100644 --- a/Modules/Branch/Resources/views/edit.blade.php +++ b/Modules/Branch/Resources/views/edit.blade.php @@ -1,4 +1,4 @@ -@extends('core::layouts.master') +@extends('layouts.app') @section('title') Edit Branch diff --git a/Modules/Branch/Resources/views/index.blade.php b/Modules/Branch/Resources/views/index.blade.php index c6a91c13..fd03400a 100644 --- a/Modules/Branch/Resources/views/index.blade.php +++ b/Modules/Branch/Resources/views/index.blade.php @@ -1,4 +1,4 @@ -@extends('core::layouts.master') +@extends('layouts.app') @section('title') Branches diff --git a/Modules/Branch/Resources/views/show.blade.php b/Modules/Branch/Resources/views/show.blade.php index 531e602b..ce04212e 100644 --- a/Modules/Branch/Resources/views/show.blade.php +++ b/Modules/Branch/Resources/views/show.blade.php @@ -1,4 +1,4 @@ -@extends('core::layouts.master') +@extends('layouts.app') @section('title') Branch Details diff --git a/Modules/Product/DataTables/ProductDataTable.php b/Modules/Product/DataTables/ProductDataTable.php index ee206a9a..1a8bc310 100644 --- a/Modules/Product/DataTables/ProductDataTable.php +++ b/Modules/Product/DataTables/ProductDataTable.php @@ -8,98 +8,92 @@ use Yajra\DataTables\Html\Column; use Yajra\DataTables\Html\Editor\Editor; use Yajra\DataTables\Html\Editor\Fields; use Yajra\DataTables\Services\DataTable; +use Illuminate\Support\Facades\DB; class ProductDataTable extends DataTable { - public function dataTable($query) { return datatables() - ->eloquent($query)->with('category') + ->eloquent($query) ->addColumn('action', function ($data) { return view('product::products.partials.actions', compact('data')); }) ->addColumn('product_image', function ($data) { - $url = $data->getFirstMediaUrl('images', 'thumb'); - return ''; + $url = $data->getFirstMediaUrl('images'); + return ''; }) - ->addColumn('product_price', function ($data) { - return format_currency($data->product_price); + ->addColumn('product_name', function ($data) { + return ''.$data->product_name.''; }) - ->addColumn('product_cost', function ($data) { - return format_currency($data->product_cost); + ->addColumn('total_quantity', function ($data) { + return $data->product_quantity; }) - ->addColumn('product_quantity', function ($data) { - return $data->product_quantity . ' ' . $data->product_unit; - }) - ->rawColumns(['product_image']); + ->rawColumns(['product_image', 'product_name', 'action']); } public function query(Product $model) { - return $model->newQuery()->with('category'); + return $model->newQuery() + ->with('category') + ->leftJoin(DB::raw('(SELECT product_id, SUM(quantity) as total_quantity FROM product_batches GROUP BY product_id) as pb'), 'products.id', '=', 'pb.product_id') + ->select('products.*', DB::raw('COALESCE(pb.total_quantity, 0) as product_quantity')); } public function html() { return $this->builder() - ->setTableId('product-table') - ->columns($this->getColumns()) - ->minifiedAjax() - ->dom("<'row'<'col-md-3'l><'col-md-5 mb-2'B><'col-md-4'f>> . + ->setTableId('product-table') + ->columns($this->getColumns()) + ->minifiedAjax() + ->dom("<'row'<'col-md-3'l><'col-md-5 mb-2'B><'col-md-4'f>> . 'tr' . <'row'<'col-md-5'i><'col-md-7 mt-2'p>>") - ->orderBy(7) - ->buttons( - Button::make('excel') - ->text(' Excel'), - Button::make('print') - ->text(' Print'), - Button::make('reset') - ->text(' Reset'), - Button::make('reload') - ->text(' Reload') - ); + ->orderBy(4) + ->buttons( + Button::make('excel') + ->text(' Excel'), + Button::make('print') + ->text(' Print'), + Button::make('reset') + ->text(' Reset'), + Button::make('reload') + ->text(' Reload') + ); } protected function getColumns() { return [ - Column::computed('product_image') + Column::make('product_image') ->title('Image') ->className('text-center align-middle'), - Column::make('category.category_name') - ->title('Category') - ->className('text-center align-middle'), - - Column::make('product_code') - ->title('Code') - ->className('text-center align-middle'), - Column::make('product_name') ->title('Name') ->className('text-center align-middle'), - Column::computed('product_cost') - ->title('Cost') + Column::make('product_code') + ->title('Code') ->className('text-center align-middle'), - Column::computed('product_price') - ->title('Price') + Column::make('category.category_name') + ->title('Category') ->className('text-center align-middle'), - Column::computed('product_quantity') + Column::make('product_unit') + ->title('Unit') + ->className('text-center align-middle'), + + Column::make('total_quantity') ->title('Quantity') ->className('text-center align-middle'), Column::computed('action') ->exportable(false) ->printable(false) - ->className('text-center align-middle'), - - Column::make('created_at') - ->visible(false) + ->className('text-center align-middle') + ->addClass('text-center'), ]; } @@ -113,3 +107,4 @@ class ProductDataTable extends DataTable return 'Product_' . date('YmdHis'); } } + diff --git a/Modules/Product/Entities/Product.php b/Modules/Product/Entities/Product.php index 3c9b5b3f..060d5cce 100644 --- a/Modules/Product/Entities/Product.php +++ b/Modules/Product/Entities/Product.php @@ -8,6 +8,7 @@ use Modules\Product\Notifications\NotifyQuantityAlert; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; +use App\Models\ProductBatch; class Product extends Model implements HasMedia { @@ -18,10 +19,20 @@ class Product extends Model implements HasMedia protected $with = ['media']; + protected $appends = ['product_quantity']; + public function category() { return $this->belongsTo(Category::class, 'category_id', 'id'); } + public function batches() { + return $this->hasMany(ProductBatch::class, 'product_id', 'id'); + } + + public function getProductQuantityAttribute() { + return $this->batches()->sum('quantity'); + } + public function registerMediaCollections(): void { $this->addMediaCollection('images') ->useFallbackUrl('/images/fallback_product_image.png'); diff --git a/Modules/Product/Http/Controllers/ProductController.php b/Modules/Product/Http/Controllers/ProductController.php index 965d54ed..4496ae5c 100644 --- a/Modules/Product/Http/Controllers/ProductController.php +++ b/Modules/Product/Http/Controllers/ProductController.php @@ -16,21 +16,24 @@ use Modules\Upload\Entities\Upload; class ProductController extends Controller { - public function index(ProductDataTable $dataTable) { + public function index(ProductDataTable $dataTable) + { abort_if(Gate::denies('access_products'), 403); return $dataTable->render('product::products.index'); } - public function create() { + public function create() + { abort_if(Gate::denies('create_products'), 403); return view('product::products.create'); } - public function store(StoreProductRequest $request) { + public function store(StoreProductRequest $request) + { $product = Product::create($request->except('document')); if ($request->has('document')) { @@ -45,21 +48,24 @@ class ProductController extends Controller } - public function show(Product $product) { + public function show(Product $product) + { abort_if(Gate::denies('show_products'), 403); return view('product::products.show', compact('product')); } - public function edit(Product $product) { + public function edit(Product $product) + { abort_if(Gate::denies('edit_products'), 403); return view('product::products.edit', compact('product')); } - public function update(UpdateProductRequest $request, Product $product) { + public function update(UpdateProductRequest $request, Product $product) + { $product->update($request->except('document')); if ($request->has('document')) { @@ -86,7 +92,8 @@ class ProductController extends Controller } - public function destroy(Product $product) { + public function destroy(Product $product) + { abort_if(Gate::denies('delete_products'), 403); $product->delete(); diff --git a/Modules/Product/Http/Requests/StoreProductRequest.php b/Modules/Product/Http/Requests/StoreProductRequest.php index 742182db..90066e44 100644 --- a/Modules/Product/Http/Requests/StoreProductRequest.php +++ b/Modules/Product/Http/Requests/StoreProductRequest.php @@ -13,24 +13,19 @@ class StoreProductRequest extends FormRequest * @return array */ public function rules() - { - return [ - 'product_name' => ['required', 'string', 'max:255'], - 'product_code' => ['required', 'string', 'max:255', 'unique:products,product_code'], - 'product_barcode_symbology' => ['required', 'string', 'max:255'], - 'product_unit' => ['required', 'string', 'max:255'], - 'product_quantity' => ['required', 'integer', 'min:1'], - 'product_cost' => ['required', 'numeric', 'max:2147483647'], - 'product_price' => ['required', 'numeric', 'max:2147483647'], - 'min_quantity_for_wholesale' => ['nullable', 'integer', 'min:0'], - 'wholesale_discount_percentage' => ['nullable', 'integer', 'min:0', 'max:100'], - 'product_stock_alert' => ['required', 'integer', 'min:0'], - 'product_order_tax' => ['nullable', 'integer', 'min:0', 'max:100'], - 'product_tax_type' => ['nullable', 'integer'], - 'product_note' => ['nullable', 'string', 'max:1000'], - 'category_id' => ['required', 'integer'] - ]; - } +{ + return [ + 'product_name' => 'required|string|max:255', + 'product_code' => 'required|string|max:255|unique:products,product_code', + 'category_id' => 'nullable|exists:categories,id', + 'product_unit' => 'nullable|string|max:50', + 'barcode_symbology' => 'nullable|string', // masih ada? kalau tidak, hapus juga + 'product_stock_alert' => 'nullable|numeric', // kalau sudah dihapus dari DB, hapus rule-nya juga + 'product_note' => 'nullable|string', + ]; +} + + /** * Determine if the user is authorized to make this request. diff --git a/Modules/Product/Http/Requests/UpdateProductRequest.php b/Modules/Product/Http/Requests/UpdateProductRequest.php index adddaadf..4c789032 100644 --- a/Modules/Product/Http/Requests/UpdateProductRequest.php +++ b/Modules/Product/Http/Requests/UpdateProductRequest.php @@ -18,14 +18,14 @@ class UpdateProductRequest extends FormRequest return [ 'product_name' => ['required', 'string', 'max:255'], 'product_code' => ['required', 'string', 'max:255', 'unique:products,product_code,' . $this->product->id], - 'product_barcode_symbology' => ['required', 'string', 'max:255'], + 'product_barcode_symbology' => ['nullable', 'string', 'max:255'], 'product_unit' => ['required', 'string', 'max:255'], - 'product_quantity' => ['required', 'integer', 'min:1'], - 'product_cost' => ['required', 'numeric', 'max:2147483647'], - 'product_price' => ['required', 'numeric', 'max:2147483647'], + 'product_quantity' => ['nullable', 'integer', 'min:0'], + 'product_cost' => ['nullable', 'numeric', 'max:2147483647'], + 'product_price' => ['nullable', 'numeric', 'max:2147483647'], 'min_quantity_for_wholesale' => ['nullable', 'integer', 'min:0'], 'wholesale_discount_percentage' => ['nullable', 'integer', 'min:0', 'max:100'], - 'product_stock_alert' => ['required', 'integer', 'min:0'], + 'product_stock_alert' => ['nullable', 'integer', 'min:0'], 'product_order_tax' => ['nullable', 'integer', 'min:0', 'max:100'], 'product_tax_type' => ['nullable', 'integer'], 'product_note' => ['nullable', 'string', 'max:1000'], diff --git a/Modules/Product/Resources/views/products/create.blade.php b/Modules/Product/Resources/views/products/create.blade.php index f7cf61e7..f04bc842 100644 --- a/Modules/Product/Resources/views/products/create.blade.php +++ b/Modules/Product/Resources/views/products/create.blade.php @@ -57,68 +57,6 @@
-
- - -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
-
-
-
-
- - -
-
-
-
- - + +
@@ -190,36 +116,27 @@ - - - @endpush diff --git a/Modules/Product/Resources/views/products/edit.blade.php b/Modules/Product/Resources/views/products/edit.blade.php index 2a71235e..afb34768 100644 --- a/Modules/Product/Resources/views/products/edit.blade.php +++ b/Modules/Product/Resources/views/products/edit.blade.php @@ -52,80 +52,6 @@
-
- - -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
- -
-
-
- - -
-
-
-
- - -
-
-
+
+
+
+
@@ -172,28 +108,27 @@ - - - @endpush diff --git a/Modules/Product/Resources/views/products/show.blade.php b/Modules/Product/Resources/views/products/show.blade.php index f3e9b5f0..f35994b3 100644 --- a/Modules/Product/Resources/views/products/show.blade.php +++ b/Modules/Product/Resources/views/products/show.blade.php @@ -12,78 +12,36 @@ @section('content')
-
-
- {!! \Milon\Barcode\Facades\DNS1DFacade::getBarCodeSVG($product->product_code, $product->product_barcode_symbology, 2, 110) !!} -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Product Code{{ $product->product_code }}
Barcode Symbology{{ $product->product_barcode_symbology }}
Name{{ $product->product_name }}
Category{{ $product->category->category_name }}
Cost{{ format_currency($product->product_cost) }}
Price{{ format_currency($product->product_price) }}
Quantity{{ $product->product_quantity . ' ' . $product->product_unit }}
Stock Worth - COST:: {{ format_currency($product->product_cost * $product->product_quantity) }} / - PRICE:: {{ format_currency($product->product_price * $product->product_quantity) }} -
Alert Quantity{{ $product->product_stock_alert }}
Tax (%){{ $product->product_order_tax ?? 'N/A' }}
Tax Type - @if($product->product_tax_type == 1) - Exclusive - @elseif($product->product_tax_type == 2) - Inclusive - @else - N/A - @endif -
Note{{ $product->product_note ?? 'N/A' }}
-
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Product Code{{ $product->product_code }}
Name{{ $product->product_name }}
Category{{ $product->category->category_name }}
Unit{{ $product->product_unit }}
Stock Alert{{ $product->product_stock_alert }}
Note{{ $product->product_note }}
@@ -91,11 +49,14 @@
- @forelse($product->getMedia('images') as $media) - Product Image - @empty - Product Image - @endforelse +
Product Images
+
+ @foreach($product->getMedia('images') as $media) +
+ {{ $product->product_name }} +
+ @endforeach +
diff --git a/Modules/Purchase/DataTables/PurchaseDataTable.php b/Modules/Purchase/DataTables/PurchaseDataTable.php index 9eb3d19b..0f1568b4 100644 --- a/Modules/Purchase/DataTables/PurchaseDataTable.php +++ b/Modules/Purchase/DataTables/PurchaseDataTable.php @@ -11,35 +11,43 @@ use Yajra\DataTables\Services\DataTable; class PurchaseDataTable extends DataTable { - - public function dataTable($query) { + public function dataTable($query) + { return datatables() ->eloquent($query) - ->addColumn('total_amount', function ($data) { + ->addColumn('action', function ($data) { + return view('purchase::partials.actions', [ + 'id' => $data->id + ]); + }) + ->editColumn('total_amount', function ($data) { return format_currency($data->total_amount); }) - ->addColumn('paid_amount', function ($data) { + ->editColumn('paid_amount', function ($data) { return format_currency($data->paid_amount); }) - ->addColumn('due_amount', function ($data) { + ->editColumn('due_amount', function ($data) { return format_currency($data->due_amount); }) - ->addColumn('status', function ($data) { - return view('purchase::partials.status', compact('data')); + ->editColumn('status', function ($data) { + return view('purchase::partials.status', [ + 'status' => $data->status + ]); }) - ->addColumn('payment_status', function ($data) { - return view('purchase::partials.payment-status', compact('data')); - }) - ->addColumn('action', function ($data) { - return view('purchase::partials.actions', compact('data')); + ->editColumn('payment_status', function ($data) { + return view('purchase::partials.payment-status', [ + 'payment_status' => $data->payment_status + ]); }); } - public function query(Purchase $model) { + public function query(Purchase $model) + { return $model->newQuery(); } - public function html() { + public function html() + { return $this->builder() ->setTableId('purchases-table') ->columns($this->getColumns()) @@ -47,50 +55,64 @@ class PurchaseDataTable extends DataTable ->dom("<'row'<'col-md-3'l><'col-md-5 mb-2'B><'col-md-4'f>> . 'tr' . <'row'<'col-md-5'i><'col-md-7 mt-2'p>>") - ->orderBy(8) + ->orderBy(0) ->buttons( Button::make('excel') - ->text(' Excel'), + ->text(' Excel') + ->className('btn btn-success btn-sm no-corner'), Button::make('print') - ->text(' Print'), + ->text(' Print') + ->className('btn btn-primary btn-sm no-corner'), Button::make('reset') - ->text(' Reset'), + ->text(' Reset') + ->className('btn btn-warning btn-sm no-corner'), Button::make('reload') ->text(' Reload') + ->className('btn btn-info btn-sm no-corner') ); } - protected function getColumns() { + protected function getColumns() + { return [ + Column::make('date') + ->title('Date') + ->className('text-center align-middle'), + Column::make('reference') + ->title('Reference') ->className('text-center align-middle'), Column::make('supplier_name') ->title('Supplier') ->className('text-center align-middle'), - Column::computed('status') + Column::make('status') + ->title('Status') ->className('text-center align-middle'), - Column::computed('total_amount') + Column::make('payment_status') + ->title('Payment Status') ->className('text-center align-middle'), - Column::computed('paid_amount') + Column::make('total_amount') + ->title('Total') ->className('text-center align-middle'), - Column::computed('due_amount') + Column::make('paid_amount') + ->title('Paid') ->className('text-center align-middle'), - Column::computed('payment_status') + Column::make('due_amount') + ->title('Due') ->className('text-center align-middle'), Column::computed('action') + ->title('Action') ->exportable(false) ->printable(false) - ->className('text-center align-middle'), - - Column::make('created_at') - ->visible(false) + ->className('text-center align-middle') + ->addClass('text-center'), ]; } diff --git a/Modules/Purchase/Database/Migrations/2024_03_21_000001_modify_purchases_table_structure.php b/Modules/Purchase/Database/Migrations/2024_03_21_000001_modify_purchases_table_structure.php new file mode 100644 index 00000000..a9a21b54 --- /dev/null +++ b/Modules/Purchase/Database/Migrations/2024_03_21_000001_modify_purchases_table_structure.php @@ -0,0 +1,71 @@ +dropColumn($column); + } + } + }); + + // Add user_id column if not exists + if (!Schema::hasColumn('purchases', 'user_id')) { + Schema::table('purchases', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->after('branch_id') + ->constrained('users')->nullOnDelete(); + }); + } + + // Modify columns to be nullable + DB::statement('ALTER TABLE purchases MODIFY discount_amount DECIMAL(10,2) NULL'); + DB::statement('ALTER TABLE purchases MODIFY due_amount DECIMAL(10,2) NULL'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Make columns non-nullable again + DB::statement('ALTER TABLE purchases MODIFY discount_amount DECIMAL(10,2) NOT NULL'); + DB::statement('ALTER TABLE purchases MODIFY due_amount DECIMAL(10,2) NOT NULL'); + + // Drop user_id column if exists + if (Schema::hasColumn('purchases', 'user_id')) { + Schema::table('purchases', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + }); + } + + // Add back dropped columns + Schema::table('purchases', function (Blueprint $table) { + if (!Schema::hasColumn('purchases', 'tax_percentage')) { + $table->decimal('tax_percentage', 10, 2)->default(0); + } + if (!Schema::hasColumn('purchases', 'tax_amount')) { + $table->decimal('tax_amount', 10, 2)->default(0); + } + if (!Schema::hasColumn('purchases', 'shipping_amount')) { + $table->decimal('shipping_amount', 10, 2)->default(0); + } + if (!Schema::hasColumn('purchases', 'status')) { + $table->string('status')->default('Pending'); + } + }); + } +}; \ No newline at end of file diff --git a/Modules/Purchase/Database/Migrations/2024_03_21_000002_add_purchase_id_to_product_batches.php b/Modules/Purchase/Database/Migrations/2024_03_21_000002_add_purchase_id_to_product_batches.php new file mode 100644 index 00000000..6cb88fda --- /dev/null +++ b/Modules/Purchase/Database/Migrations/2024_03_21_000002_add_purchase_id_to_product_batches.php @@ -0,0 +1,36 @@ +foreignId('purchase_id')->nullable()->after('expired_date') + ->constrained('purchases')->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('product_batches', function (Blueprint $table) { + // Drop purchase_id column if exists + if (Schema::hasColumn('product_batches', 'purchase_id')) { + $table->dropForeign(['purchase_id']); + $table->dropColumn('purchase_id'); + } + }); + } +}; \ No newline at end of file diff --git a/Modules/Purchase/Database/Migrations/2024_03_21_update_purchases_table.php b/Modules/Purchase/Database/Migrations/2024_03_21_update_purchases_table.php new file mode 100644 index 00000000..55121055 --- /dev/null +++ b/Modules/Purchase/Database/Migrations/2024_03_21_update_purchases_table.php @@ -0,0 +1,48 @@ +dropColumn([ + 'tax_percentage', + 'tax_amount', + 'shipping_amount', + 'status' + ]); + + // Make columns nullable + $table->decimal('discount_amount', 10, 2)->nullable()->change(); + $table->decimal('due_amount', 10, 2)->nullable()->change(); + + // Add user_id column + $table->foreignId('user_id')->nullable()->after('branch_id') + ->constrained('users')->nullOnDelete(); + }); + } + + public function down() + { + Schema::table('purchases', function (Blueprint $table) { + // Revert nullable columns + $table->decimal('discount_amount', 10, 2)->nullable(false)->change(); + $table->decimal('due_amount', 10, 2)->nullable(false)->change(); + + // Drop user_id column + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + + // Add back dropped columns + $table->decimal('tax_percentage', 10, 2)->default(0); + $table->decimal('tax_amount', 10, 2)->default(0); + $table->decimal('shipping_amount', 10, 2)->default(0); + $table->string('status')->default('Pending'); + }); + } +}; \ No newline at end of file diff --git a/Modules/Purchase/Entities/ProductBatch.php b/Modules/Purchase/Entities/ProductBatch.php new file mode 100644 index 00000000..84c177a8 --- /dev/null +++ b/Modules/Purchase/Entities/ProductBatch.php @@ -0,0 +1,88 @@ + 'integer', + 'purchase_price' => 'decimal:2', + 'expired_date' => 'date' + ]; + + public function product(): BelongsTo + { + return $this->belongsTo('Modules\Product\Entities\Product'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo('App\Models\Branch'); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } + + public static function addStock(array $data) + { + return DB::transaction(function () use ($data) { + // Check if batch with same product, branch, and expired_date exists + $batch = self::where('product_id', $data['product_id']) + ->where('branch_id', $data['branch_id']) + ->where('expired_date', $data['expired_date']) + ->first(); + + if ($batch) { + // Update existing batch + $batch->update([ + 'quantity' => $batch->quantity + $data['quantity'], + 'purchase_price' => $data['purchase_price'], // Update price to latest + 'updated_by' => auth()->user()->name + ]); + return $batch; + } + + // Create new batch + return self::create([ + 'product_id' => $data['product_id'], + 'branch_id' => $data['branch_id'], + 'batch_code' => $data['batch_code'] ?? null, + 'quantity' => $data['quantity'], + 'purchase_price' => $data['purchase_price'], + 'expired_date' => $data['expired_date'], + 'purchase_id' => $data['purchase_id'], + 'created_by' => auth()->user()->name, + 'updated_by' => auth()->user()->name + ]); + }); + } + + public static function getAvailableStock($productId, $branchId) + { + return self::where('product_id', $productId) + ->where('branch_id', $branchId) + ->where(function ($query) { + $query->whereNull('expired_date') + ->orWhere('expired_date', '>', now()); + }) + ->sum('quantity'); + } +} \ No newline at end of file diff --git a/Modules/Purchase/Entities/Purchase.php b/Modules/Purchase/Entities/Purchase.php index c7af85f6..f92192ed 100644 --- a/Modules/Purchase/Entities/Purchase.php +++ b/Modules/Purchase/Entities/Purchase.php @@ -4,15 +4,55 @@ namespace Modules\Purchase\Entities; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class Purchase extends Model { use HasFactory; - protected $guarded = []; + protected $fillable = [ + 'reference_no', + 'supplier_id', + 'date', + 'discount_amount', + 'payment_method', + 'paid_amount', + 'total_amount', + 'due_amount', + 'payment_status', + 'user_id', + 'branch_id', + 'created_by', + 'updated_by' + ]; - public function purchaseDetails() { - return $this->hasMany(PurchaseDetail::class, 'purchase_id', 'id'); + protected $casts = [ + 'date' => 'date', + 'paid_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'due_amount' => 'decimal:2', + 'discount_amount' => 'decimal:2' + ]; + + public function purchaseDetails(): HasMany + { + return $this->hasMany(PurchaseDetail::class); + } + + public function supplier(): BelongsTo + { + return $this->belongsTo('Modules\People\Entities\Supplier'); + } + + public function user(): BelongsTo + { + return $this->belongsTo('App\Models\User'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo('App\Models\Branch'); } public function purchasePayments() { @@ -24,16 +64,12 @@ class Purchase extends Model static::creating(function ($model) { $number = Purchase::max('id') + 1; - $model->reference = make_reference_id('PR', $number); + $model->reference_no = make_reference_id('PR', $number); }); } public function scopeCompleted($query) { - return $query->where('status', 'Completed'); - } - - public function getShippingAmountAttribute($value) { - return $value / 100; + return $query->where('payment_status', 'Paid'); } public function getPaidAmountAttribute($value) { @@ -48,10 +84,6 @@ class Purchase extends Model return $value / 100; } - public function getTaxAmountAttribute($value) { - return $value / 100; - } - public function getDiscountAmountAttribute($value) { return $value / 100; } diff --git a/Modules/Purchase/Entities/PurchaseDetail.php b/Modules/Purchase/Entities/PurchaseDetail.php index 7beadeda..e6c0756a 100644 --- a/Modules/Purchase/Entities/PurchaseDetail.php +++ b/Modules/Purchase/Entities/PurchaseDetail.php @@ -5,20 +5,38 @@ namespace Modules\Purchase\Entities; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; use Modules\Product\Entities\Product; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class PurchaseDetail extends Model { use HasFactory; - protected $guarded = []; + protected $fillable = [ + 'purchase_id', + 'product_id', + 'product_name', + 'quantity', + 'price', + 'sub_total', + 'created_by', + 'updated_by' + ]; + + protected $casts = [ + 'quantity' => 'integer', + 'price' => 'decimal:2', + 'sub_total' => 'decimal:2' + ]; protected $with = ['product']; - public function product() { + public function product(): BelongsTo + { return $this->belongsTo(Product::class, 'product_id', 'id'); } - public function purchase() { + public function purchase(): BelongsTo + { return $this->belongsTo(Purchase::class, 'purchase_id', 'id'); } diff --git a/Modules/Purchase/Http/Controllers/PurchaseController.php b/Modules/Purchase/Http/Controllers/PurchaseController.php index 287b2a39..68ca07de 100644 --- a/Modules/Purchase/Http/Controllers/PurchaseController.php +++ b/Modules/Purchase/Http/Controllers/PurchaseController.php @@ -14,6 +14,10 @@ use Modules\Purchase\Entities\PurchaseDetail; use Modules\Purchase\Entities\PurchasePayment; use Modules\Purchase\Http\Requests\StorePurchaseRequest; use Modules\Purchase\Http\Requests\UpdatePurchaseRequest; +use App\Models\ProductBatch; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; +use Barryvdh\DomPDF\Facade\Pdf; class PurchaseController extends Controller { @@ -25,83 +29,143 @@ class PurchaseController extends Controller } - public function create() { - abort_if(Gate::denies('create_purchases'), 403); - - Cart::instance('purchase')->destroy(); - + public function create() + { return view('purchase::create'); } - public function store(StorePurchaseRequest $request) { - DB::transaction(function () use ($request) { - $due_amount = $request->total_amount - $request->paid_amount; - if ($due_amount == $request->total_amount) { - $payment_status = 'Unpaid'; - } elseif ($due_amount > 0) { - $payment_status = 'Partial'; - } else { - $payment_status = 'Paid'; - } + public function store(Request $request) + { + $validator = Validator::make($request->all(), [ + 'reference_no' => 'required|string', + 'supplier_id' => 'required|exists:suppliers,id', + 'date' => 'required|date', + 'payment_method' => 'required|string', + 'paid_amount' => 'required|numeric|min:0', + 'products' => 'required|array|min:1', + 'products.*.product_id' => 'required|exists:products,id', + 'products.*.quantity' => 'required|integer|min:1', + 'products.*.purchase_price' => 'required|numeric|min:0', + 'products.*.expired_date' => 'nullable|date|after:today', + 'discount_percentage' => 'nullable|numeric|min:0', + 'discount_amount' => 'nullable|numeric|min:0', + 'note' => 'nullable|string' + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors() + ], 422); + } + + DB::beginTransaction(); + + try { + // Calculate total amount + $totalAmount = collect($request->products)->sum(function ($product) { + return $product['quantity'] * $product['purchase_price']; + }); + + // Calculate due amount + $dueAmount = $totalAmount - $request->paid_amount; + + // Determine payment status + $paymentStatus = $request->paid_amount == 0 ? 'Unpaid' : + ($dueAmount == 0 ? 'Paid' : 'Partial'); + + // 1. Simpan ke purchases $purchase = Purchase::create([ - 'date' => $request->date, + 'reference_no' => $request->reference_no, 'supplier_id' => $request->supplier_id, - 'supplier_name' => Supplier::findOrFail($request->supplier_id)->supplier_name, - 'tax_percentage' => $request->tax_percentage, - 'discount_percentage' => $request->discount_percentage, - 'shipping_amount' => $request->shipping_amount * 100, - 'paid_amount' => $request->paid_amount * 100, - 'total_amount' => $request->total_amount * 100, - 'due_amount' => $due_amount * 100, - 'status' => $request->status, - 'payment_status' => $payment_status, + 'supplier_name' => Supplier::findOrFail($request->supplier_id)->name, + 'date' => $request->date, + 'discount_percentage' => $request->discount_percentage ?? 0, + //'discount_amount' => $request->discount_amount ?? 0, 'payment_method' => $request->payment_method, + 'paid_amount' => round($request->paid_amount * 100), + 'total_amount' => round($totalAmount * 100), + 'discount_amount' => round($request->discount_amount * 100), + 'due_amount' => $dueAmount, + 'payment_status' => $paymentStatus, 'note' => $request->note, - 'tax_amount' => Cart::instance('purchase')->tax() * 100, - 'discount_amount' => Cart::instance('purchase')->discount() * 100, + 'user_id' => auth()->id(), + 'branch_id' => session(['active_branch' => 1]),// contoh ID cabang default + 'created_by' => auth()->user()->name, + 'updated_by' => auth()->user()->name ]); - foreach (Cart::instance('purchase')->content() as $cart_item) { + // 2. Simpan detail produk & batch + foreach ($request->products as $product) { + // Detail PurchaseDetail::create([ 'purchase_id' => $purchase->id, - 'product_id' => $cart_item->id, - 'product_name' => $cart_item->name, - 'product_code' => $cart_item->options->code, - 'quantity' => $cart_item->qty, - 'price' => $cart_item->price * 100, - 'unit_price' => $cart_item->options->unit_price * 100, - 'sub_total' => $cart_item->options->sub_total * 100, - 'product_discount_amount' => $cart_item->options->product_discount * 100, - 'product_discount_type' => $cart_item->options->product_discount_type, - 'product_tax_amount' => $cart_item->options->product_tax * 100, + 'product_id' => $product['product_id'], + 'product_name' => Product::findOrFail($product['product_id'])->product_name, + 'product_code' => Product::findOrFail($product['product_id'])->product_code, + 'quantity' => $product['quantity'], + 'price' => $product['purchase_price'], + 'unit_price' => $product['purchase_price'], + 'sub_total' => $product['quantity'] * $product['purchase_price'], + 'product_discount_amount' => 0, + 'product_discount_type' => 'fixed', + 'product_tax_amount' => 0, + 'created_by' => auth()->user()->name, + 'updated_by' => auth()->user()->name ]); - if ($request->status == 'Completed') { - $product = Product::findOrFail($cart_item->id); - $product->update([ - 'product_quantity' => $product->product_quantity + $cart_item->qty - ]); - } + // Batch + ProductBatch::addStock([ + 'product_id' => $product['product_id'], + 'branch_id' => session(['active_branch' => 1]), // contoh ID cabang default, + 'quantity' => $product['quantity'], + 'purchase_price' => $product['purchase_price'], + 'expired_date' => $product['expired_date'], + 'purchase_id' => $purchase->id, + 'batch_code' => $purchase->reference_no . '-' . $product['product_id'], + 'created_by' => auth()->user()->name, + 'updated_by' => auth()->user()->name + ]); } - Cart::instance('purchase')->destroy(); - + // 3. Simpan pembayaran (jika ada) if ($purchase->paid_amount > 0) { PurchasePayment::create([ - 'date' => $request->date, - 'reference' => 'INV/'.$purchase->reference, - 'amount' => $purchase->paid_amount, 'purchase_id' => $purchase->id, - 'payment_method' => $request->payment_method + 'branch_id' => session(['active_branch' => 1]), // contoh ID cabang default, + 'amount' => $purchase->paid_amount, + 'date' => $purchase->date, + 'reference' => 'PAY-' . $purchase->reference_no, + 'payment_method' => $purchase->payment_method, + 'note' => 'Initial payment for purchase ' . $purchase->reference_no, + 'created_by' => auth()->user()->name, + 'updated_by' => auth()->user()->name ]); } - }); - toast('Purchase Created!', 'success'); + DB::commit(); - return redirect()->route('purchases.index'); + return response()->json([ + 'message' => 'Purchase created successfully', + 'data' => $purchase + ], 201); + + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'message' => 'Failed to create purchase', + 'error' => $e->getMessage() + ], 500); + } + } + + public function getStock(Request $request, $productId, $branchId) + { + $stock = ProductBatch::getAvailableStock($productId, $branchId); + return response()->json([ + 'data' => $stock + ]); } @@ -227,4 +291,17 @@ class PurchaseController extends Controller return redirect()->route('purchases.index'); } + + public function pdf($id) + { + $purchase = Purchase::findOrFail($id); + $supplier = Supplier::findOrFail($purchase->supplier_id); + + $pdf = PDF::loadView('purchase::print', [ + 'purchase' => $purchase, + 'supplier' => $supplier, + ])->setPaper('a4'); + + return $pdf->stream('purchase-'. $purchase->reference .'.pdf'); + } } diff --git a/Modules/Purchase/Http/Livewire/CreatePurchase.php b/Modules/Purchase/Http/Livewire/CreatePurchase.php new file mode 100644 index 00000000..6b0d4e87 --- /dev/null +++ b/Modules/Purchase/Http/Livewire/CreatePurchase.php @@ -0,0 +1,230 @@ + 'required|exists:suppliers,id', + 'reference_no' => 'required|unique:purchases,reference_no', + 'date' => 'required|date', + 'payment_method' => 'required|string', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.qty' => 'required|integer|min:1', + 'items.*.purchase_price' => 'required|numeric|min:0', + 'items.*.unit_price' => 'required|numeric|min:0', + 'discount' => 'nullable|numeric|min:0', + 'discount_percentage' => 'nullable|numeric|min:0|max:100', + 'paid_amount' => 'nullable|numeric|min:0', + 'note' => 'nullable|string', + ]; + + protected $messages = [ + 'supplier_id.required' => 'Please select a supplier', + 'supplier_id.exists' => 'The selected supplier is invalid', + 'reference_no.required' => 'Reference number is required', + 'reference_no.unique' => 'This reference number has already been used', + 'date.required' => 'Date is required', + 'date.date' => 'Please enter a valid date', + 'payment_method.required' => 'Please select a payment method', + 'items.required' => 'At least one product is required', + 'items.min' => 'At least one product is required', + 'items.*.product_id.required' => 'Product is required', + 'items.*.product_id.exists' => 'Selected product is invalid', + 'items.*.qty.required' => 'Quantity is required', + 'items.*.qty.min' => 'Quantity must be at least 1', + 'items.*.purchase_price.required' => 'Purchase price is required', + 'items.*.purchase_price.min' => 'Purchase price must be greater than 0', + 'items.*.unit_price.required' => 'Unit price is required', + 'items.*.unit_price.min' => 'Unit price must be greater than 0', + 'discount.numeric' => 'Discount must be a number', + 'discount.min' => 'Discount cannot be negative', + 'discount_percentage.numeric' => 'Discount percentage must be a number', + 'discount_percentage.min' => 'Discount percentage cannot be negative', + 'discount_percentage.max' => 'Discount percentage cannot be more than 100', + 'paid_amount.numeric' => 'Paid amount must be a number', + 'paid_amount.min' => 'Paid amount cannot be negative', + ]; + + public function mount() + { + $this->date = date('Y-m-d'); + $this->reference_no = 'PR-' . date('Ymd') . '-' . rand(1000, 9999); + } + + public function addItem() + { + $this->items[] = [ + 'product_id' => '', + 'qty' => 1, + 'purchase_price' => 0, + 'unit_price' => 0, + 'discount' => 0, + 'discount_type' => null, + 'tax' => 0 + ]; + } + + public function removeItem($index) + { + unset($this->items[$index]); + $this->items = array_values($this->items); + $this->calculateTotal(); + } + + public function calculateTotal() + { + $total = 0; + foreach ($this->items as $item) { + $subtotal = $item['qty'] * $item['unit_price']; + $total += $subtotal; + } + $this->total_amount = $total; + $this->calculateTotalAfterDiscount(); + } + + public function calculateTotalAfterDiscount() + { + $this->total_after_discount = $this->total_amount - $this->discount; + $this->due_amount = $this->total_after_discount - $this->paid_amount; + } + + public function updated($propertyName) + { + $this->validateOnly($propertyName); + + if (str_contains($propertyName, 'items')) { + $this->calculateTotal(); + } + + if (in_array($propertyName, ['discount', 'discount_percentage', 'paid_amount'])) { + $this->calculateTotalAfterDiscount(); + } + } + + public function save() + { + $this->validate(); + + try { + DB::beginTransaction(); + + $supplier = Supplier::findOrFail($this->supplier_id); + + $purchase = Purchase::create([ + 'branch_id' => session('branch_id'), + 'user_id' => auth()->id(), + 'date' => $this->date, + 'reference_no' => $this->reference_no, + 'supplier_id' => $this->supplier_id, + 'supplier_name' => $supplier->supplier_name, + 'discount_percentage' => $this->discount_percentage, + 'discount' => $this->discount, + 'total' => $this->total_amount, + 'paid_amount' => $this->paid_amount, + 'due_amount' => $this->due_amount, + 'payment_status' => ($this->paid_amount >= $this->total_after_discount) ? 'paid' : 'due', + 'payment_method' => $this->payment_method, + 'note' => $this->note, + ]); + + // Save purchase items and update product quantities + foreach ($this->items as $item) { + $product = Product::findOrFail($item['product_id']); + + // Create purchase detail + PurchaseDetail::create([ + 'purchase_id' => $purchase->id, + 'product_id' => $item['product_id'], + 'product_name' => $product->product_name, + 'product_code' => $product->product_code, + 'qty' => $item['qty'], + 'purchase_price' => $item['purchase_price'], + 'unit_price' => $item['unit_price'], + 'subtotal' => $item['qty'] * $item['unit_price'], + 'product_discount_amount' => $item['discount'] ?? 0, + 'product_discount_type' => $item['discount_type'] ?? null, + 'product_tax_amount' => $item['tax'] ?? 0, + ]); + + // Update product quantity + $product->update([ + 'product_quantity' => $product->product_quantity + $item['qty'] + ]); + + // Create or update product batch + DB::table('product_batches')->updateOrInsert( + [ + 'product_id' => $item['product_id'], + 'batch_number' => $this->reference_no, + ], + [ + 'product_id' => $item['product_id'], + 'batch_number' => $this->reference_no, + 'qty' => $item['qty'], + 'expiry_date' => null, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + // Create purchase payment if there's a paid amount + if ($this->paid_amount > 0) { + PurchasePayment::create([ + 'branch_id' => session('branch_id'), + 'purchase_id' => $purchase->id, + 'amount' => $this->paid_amount, + 'date' => now(), + 'reference' => 'PAY-' . strtoupper(Str::random(6)), + 'payment_method' => $this->payment_method, + 'note' => $this->note, + ]); + } + + DB::commit(); + + $this->dispatch('showSuccessMessage', [ + 'message' => 'Purchase created successfully!', + 'redirect' => route('purchases.index') + ]); + + } catch (\Exception $e) { + DB::rollBack(); + $this->dispatch('showErrorMessage', [ + 'message' => 'Error creating purchase: ' . $e->getMessage() + ]); + } + } + + public function render() + { + return view('purchase::livewire.create-purchase', [ + 'suppliers' => Supplier::all(), + 'products' => Product::all() + ]); + } +} \ No newline at end of file diff --git a/Modules/Purchase/Http/Livewire/PurchaseList.php b/Modules/Purchase/Http/Livewire/PurchaseList.php new file mode 100644 index 00000000..9efbe75e --- /dev/null +++ b/Modules/Purchase/Http/Livewire/PurchaseList.php @@ -0,0 +1,92 @@ + ['except' => ''], + 'dateFrom' => ['except' => ''], + 'dateTo' => ['except' => ''], + 'supplier_id' => ['except' => ''], + 'payment_status' => ['except' => ''], + ]; + + public function updatingSearch() + { + $this->resetPage(); + } + + public function deletePurchase($id) + { + $purchase = Purchase::findOrFail($id); + + if ($purchase->payment_status === 'Paid') { + session()->flash('error', 'Cannot delete a paid purchase.'); + return; + } + + try { + DB::beginTransaction(); + + // Delete related records + $purchase->purchaseDetails()->delete(); + $purchase->purchasePayments()->delete(); + + // Delete the purchase + $purchase->delete(); + + DB::commit(); + session()->flash('message', 'Purchase deleted successfully.'); + } catch (\Exception $e) { + DB::rollBack(); + session()->flash('error', 'Error deleting purchase: ' . $e->getMessage()); + } + } + + public function render() + { + $purchases = Purchase::query() + ->when($this->search, function ($query) { + $query->where('reference_no', 'like', '%' . $this->search . '%') + ->orWhereHas('supplier', function ($q) { + $q->where('name', 'like', '%' . $this->search . '%'); + }); + }) + ->when($this->dateFrom, function ($query) { + $query->whereDate('date', '>=', $this->dateFrom); + }) + ->when($this->dateTo, function ($query) { + $query->whereDate('date', '<=', $this->dateTo); + }) + ->when($this->supplier_id, function ($query) { + $query->where('supplier_id', $this->supplier_id); + }) + ->when($this->payment_status, function ($query) { + $query->where('payment_status', $this->payment_status); + }) + ->where('branch_id', session('active_branch')) + ->latest() + ->paginate($this->perPage); + + return view('purchase::livewire.purchase-list', [ + 'purchases' => $purchases, + 'suppliers' => \Modules\People\Entities\Supplier::all() + ]); + } +} \ No newline at end of file diff --git a/Modules/Purchase/Http/Livewire/PurchaseShow.php b/Modules/Purchase/Http/Livewire/PurchaseShow.php new file mode 100644 index 00000000..26ec32b0 --- /dev/null +++ b/Modules/Purchase/Http/Livewire/PurchaseShow.php @@ -0,0 +1,75 @@ + 'required|numeric|min:0', + 'payment_method' => 'required', + 'payment_note' => 'nullable|string|max:255' + ]; + + public function mount($id) + { + $this->purchase_id = $id; + $this->loadPurchase(); + } + + public function loadPurchase() + { + $this->purchase = Purchase::with(['supplier', 'purchaseDetails.product', 'purchasePayments']) + ->findOrFail($this->purchase_id); + } + + public function addPayment() + { + $this->validate(); + + if ($this->payment_amount > $this->purchase->due_amount) { + session()->flash('error', 'Payment amount cannot be greater than due amount.'); + return; + } + + try { + $this->purchase->purchasePayments()->create([ + 'amount' => $this->payment_amount, + 'payment_method' => $this->payment_method, + 'note' => $this->payment_note, + 'user_id' => Auth::id(), + 'created_by' => Auth::user()->name, + 'updated_by' => Auth::user()->name + ]); + + // Update purchase payment status + $total_paid = $this->purchase->purchasePayments()->sum('amount'); + $this->purchase->update([ + 'paid_amount' => $total_paid, + 'due_amount' => $this->purchase->total_amount - $total_paid, + 'payment_status' => $total_paid >= $this->purchase->total_amount ? 'Paid' : 'Partial', + 'updated_by' => Auth::user()->name + ]); + + $this->reset(['payment_amount', 'payment_method', 'payment_note']); + $this->loadPurchase(); + session()->flash('message', 'Payment added successfully.'); + } catch (\Exception $e) { + session()->flash('error', 'Error adding payment: ' . $e->getMessage()); + } + } + + public function render() + { + return view('purchase::livewire.purchase-show'); + } +} \ No newline at end of file diff --git a/Modules/Purchase/Providers/PurchaseServiceProvider.php b/Modules/Purchase/Providers/PurchaseServiceProvider.php index 397fdb9f..d2ad72db 100644 --- a/Modules/Purchase/Providers/PurchaseServiceProvider.php +++ b/Modules/Purchase/Providers/PurchaseServiceProvider.php @@ -4,6 +4,8 @@ namespace Modules\Purchase\Providers; use Illuminate\Support\ServiceProvider; use Illuminate\Database\Eloquent\Factory; +use Livewire\Livewire; +use Modules\Purchase\Http\Livewire\CreatePurchase; class PurchaseServiceProvider extends ServiceProvider { @@ -24,10 +26,13 @@ class PurchaseServiceProvider extends ServiceProvider */ public function boot() { - $this->registerTranslations(); $this->registerConfig(); $this->registerViews(); $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations')); + $this->loadRoutesFrom(module_path($this->moduleName, 'Routes/web.php')); + + // Register Livewire Component + Livewire::component('purchase::create-purchase', CreatePurchase::class); } /** @@ -63,7 +68,6 @@ class PurchaseServiceProvider extends ServiceProvider public function registerViews() { $viewPath = resource_path('views/modules/' . $this->moduleNameLower); - $sourcePath = module_path($this->moduleName, 'Resources/views'); $this->publishes([ diff --git a/Modules/Purchase/Providers/RouteServiceProvider.php b/Modules/Purchase/Providers/RouteServiceProvider.php index 55cea7a2..f7844f17 100644 --- a/Modules/Purchase/Providers/RouteServiceProvider.php +++ b/Modules/Purchase/Providers/RouteServiceProvider.php @@ -33,8 +33,6 @@ class RouteServiceProvider extends ServiceProvider */ public function map() { - $this->mapApiRoutes(); - $this->mapWebRoutes(); } @@ -49,7 +47,7 @@ class RouteServiceProvider extends ServiceProvider { Route::middleware('web') ->namespace($this->moduleNamespace) - ->group(module_path('Purchase', '/Routes/web.php')); + ->group(module_path('Purchase', 'Routes/web.php')); } /** diff --git a/Modules/Purchase/Resources/views/create.blade.php b/Modules/Purchase/Resources/views/create.blade.php index 73b924b4..ee4a13a7 100644 --- a/Modules/Purchase/Resources/views/create.blade.php +++ b/Modules/Purchase/Resources/views/create.blade.php @@ -2,138 +2,6 @@ @section('title', 'Create Purchase') -@section('breadcrumb') - -@endsection - @section('content') -
-
-
- -
-
- -
-
-
-
- @include('utils.alerts') -
- @csrf - -
-
-
- - -
-
-
-
-
- - -
-
-
-
-
-
- - -
-
-
-
- - - -
-
-
- - -
-
-
-
-
- - -
-
-
-
-
- -
- -
- -
-
-
-
-
- -
- - -
- -
- -
- -
-
-
-
-
+ @endsection - -@push('page_scripts') - - -@endpush diff --git a/Modules/Purchase/Resources/views/index.blade.php b/Modules/Purchase/Resources/views/index.blade.php index 5f9f3478..84c37368 100644 --- a/Modules/Purchase/Resources/views/index.blade.php +++ b/Modules/Purchase/Resources/views/index.blade.php @@ -14,27 +14,21 @@ @endsection @section('content') -
-
-
-
-
- - Add Purchase - - -
- -
- {!! $dataTable->table() !!} -
-
-
+
+
+

Purchases

+
+
+ {{ $dataTable->table() }} +
@endsection -@push('page_scripts') - {!! $dataTable->scripts() !!} +@push('scripts') + {{ $dataTable->scripts() }} @endpush diff --git a/Modules/Purchase/Resources/views/livewire/create-purchase.blade.php b/Modules/Purchase/Resources/views/livewire/create-purchase.blade.php new file mode 100644 index 00000000..29f852f8 --- /dev/null +++ b/Modules/Purchase/Resources/views/livewire/create-purchase.blade.php @@ -0,0 +1,252 @@ +@push('scripts') + +@endpush + +
+
+
+

Create Purchase

+
+
+
+
+
+
+ + + @error('reference_no') {{ $message }} @enderror +
+
+
+
+ + + @error('supplier_id') {{ $message }} @enderror +
+
+
+
+ + + @error('date') {{ $message }} @enderror +
+
+
+ +
+
+
+ + + @error('payment_method') {{ $message }} @enderror +
+
+
+
+ + + @error('discount_percentage') {{ $message }} @enderror +
+
+
+
+ + + @error('discount') {{ $message }} @enderror +
+
+
+ +
+
+
+

Products

+ +
+ + @error('items') {{ $message }} @enderror + +
+ + + + + + + + + + + + + + + @foreach($items as $index => $item) + + + + + + + + + + + @endforeach + + + + + + + + + + + + + + + +
ProductQuantityPurchase PriceUnit PriceDiscountTaxSubtotalAction
+ + @error("items.{$index}.product_id") {{ $message }} @enderror + + + @error("items.{$index}.qty") {{ $message }} @enderror + + + @error("items.{$index}.purchase_price") {{ $message }} @enderror + + + @error("items.{$index}.unit_price") {{ $message }} @enderror + +
+ + +
+
+ + + {{ number_format($item['qty'] * $item['unit_price'], 2) }} + + +
Subtotal: + {{ number_format($total_amount, 2) }} +
Discount: + {{ number_format($discount, 2) }} +
Total After Discount: + {{ number_format($total_after_discount, 2) }} +
+
+
+
+ +
+
+
+ + + @error('paid_amount') {{ $message }} @enderror +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + + @error('note') {{ $message }} @enderror +
+
+
+ +
+
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/Modules/Purchase/Resources/views/livewire/purchase-list.blade.php b/Modules/Purchase/Resources/views/livewire/purchase-list.blade.php new file mode 100644 index 00000000..4daab923 --- /dev/null +++ b/Modules/Purchase/Resources/views/livewire/purchase-list.blade.php @@ -0,0 +1,134 @@ +
+
+
+
+
+

Purchase List

+
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + + @forelse($purchases as $purchase) + + + + + + + + + + + + @empty + + + + @endforelse + +
ReferenceDateSupplierTotal AmountPaid AmountDue AmountPayment StatusCreated ByActions
{{ $purchase->reference_no }}{{ $purchase->date->format('d/m/Y') }}{{ $purchase->supplier->name }}{{ number_format($purchase->total_amount, 2) }}{{ number_format($purchase->paid_amount, 2) }}{{ $purchase->due_amount ? number_format($purchase->due_amount, 2) : '0.00' }} + + {{ $purchase->payment_status }} + + {{ $purchase->created_by }} +
+ + + + @if($purchase->payment_status !== 'Paid') + + + + + @endif +
+
No purchases found.
+
+ + +
+ {{ $purchases->links() }} +
+
+
+ + @if (session()->has('message')) + + @endif + + @if (session()->has('error')) + + @endif +
\ No newline at end of file diff --git a/Modules/Purchase/Resources/views/livewire/purchase-show.blade.php b/Modules/Purchase/Resources/views/livewire/purchase-show.blade.php new file mode 100644 index 00000000..20370155 --- /dev/null +++ b/Modules/Purchase/Resources/views/livewire/purchase-show.blade.php @@ -0,0 +1,195 @@ +
+
+
+
+
+

Purchase Details

+
+
+ + Back to List + + @if($purchase->payment_status !== 'Paid') + + Edit + + @endif +
+
+
+ +
+ +
+
+
Purchase Information
+ + + + + + + + + + + + + + + + + +
Reference No{{ $purchase->reference_no }}
Date{{ $purchase->date->format('d/m/Y') }}
Supplier{{ $purchase->supplier->name }}
Created By{{ $purchase->created_by }}
+
+
+
Payment Information
+ + + + + + + + + + + + + + + + + +
Total Amount{{ number_format($purchase->total_amount, 2) }}
Paid Amount{{ number_format($purchase->paid_amount, 2) }}
Due Amount{{ $purchase->due_amount ? number_format($purchase->due_amount, 2) : '0.00' }}
Payment Status + + {{ $purchase->payment_status }} + +
+
+
+ + +
Purchase Details
+
+ + + + + + + + + + + @foreach($purchase->purchaseDetails as $detail) + + + + + + + @endforeach + +
ProductQuantityPriceSubtotal
{{ $detail->product_name }}{{ $detail->quantity }}{{ number_format($detail->price, 2) }}{{ number_format($detail->sub_total, 2) }}
+
+ + +
Payment History
+
+ + + + + + + + + + + + @forelse($purchase->purchasePayments as $payment) + + + + + + + + @empty + + + + @endforelse + +
DateAmountMethodNoteCreated By
{{ $payment->created_at->format('d/m/Y H:i') }}{{ number_format($payment->amount, 2) }}{{ $payment->payment_method }}{{ $payment->note }}{{ $payment->created_by }}
No payments found.
+
+ + + @if($purchase->payment_status !== 'Paid') +
+
+
Add Payment
+
+
+
+
+
+
+ + + @error('payment_amount') {{ $message }} @enderror +
+
+
+
+ + + @error('payment_method') {{ $message }} @enderror +
+
+
+
+ + + @error('payment_note') {{ $message }} @enderror +
+
+
+
+ + +
+
+
+
+
+
+ @endif +
+
+ + @if (session()->has('message')) + + @endif + + @if (session()->has('error')) + + @endif +
\ No newline at end of file diff --git a/Modules/Purchase/Resources/views/partials/actions.blade.php b/Modules/Purchase/Resources/views/partials/actions.blade.php index e01e8a7f..3e92bfc7 100644 --- a/Modules/Purchase/Resources/views/partials/actions.blade.php +++ b/Modules/Purchase/Resources/views/partials/actions.blade.php @@ -1,42 +1,41 @@ -
- -
+ +@push('scripts') + +@endpush diff --git a/Modules/Purchase/Routes/web.php b/Modules/Purchase/Routes/web.php index 079b2300..2a2eff7a 100644 --- a/Modules/Purchase/Routes/web.php +++ b/Modules/Purchase/Routes/web.php @@ -11,30 +11,36 @@ | */ -Route::group(['middleware' => 'auth'], function () { +use Illuminate\Support\Facades\Route; +use Modules\Purchase\Http\Controllers\PurchaseController; +use Modules\Purchase\Http\Livewire\CreatePurchase; - //Generate PDF - Route::get('/purchases/pdf/{id}', function ($id) { - $purchase = \Modules\Purchase\Entities\Purchase::findOrFail($id); - $supplier = \Modules\People\Entities\Supplier::findOrFail($purchase->supplier_id); +Route::middleware(['web', 'auth'])->group(function () { + // Purchases + Route::prefix('purchases')->group(function () { + Route::get('/', [PurchaseController::class, 'index'])->name('purchases.index'); + Route::get('/create', [PurchaseController::class, 'create'])->name('purchases.create'); + Route::post('/', [PurchaseController::class, 'store'])->name('purchases.store'); + Route::get('/{purchase}', [PurchaseController::class, 'show'])->name('purchases.show'); + Route::get('/{purchase}/edit', [PurchaseController::class, 'edit'])->name('purchases.edit'); + Route::put('/{purchase}', [PurchaseController::class, 'update'])->name('purchases.update'); + Route::delete('/{purchase}', [PurchaseController::class, 'destroy'])->name('purchases.destroy'); + Route::get('/pdf/{id}', [PurchaseController::class, 'pdf'])->name('purchases.pdf'); + Route::get('/stock/{productId}/{branchId}', [PurchaseController::class, 'getStock'])->name('purchases.stock'); + }); - $pdf = \PDF::loadView('purchase::print', [ - 'purchase' => $purchase, - 'supplier' => $supplier, - ])->setPaper('a4'); - - return $pdf->stream('purchase-'. $purchase->reference .'.pdf'); - })->name('purchases.pdf'); - - //Sales - Route::resource('purchases', 'PurchaseController'); - - //Payments - Route::get('/purchase-payments/{purchase_id}', 'PurchasePaymentsController@index')->name('purchase-payments.index'); - Route::get('/purchase-payments/{purchase_id}/create', 'PurchasePaymentsController@create')->name('purchase-payments.create'); - Route::post('/purchase-payments/store', 'PurchasePaymentsController@store')->name('purchase-payments.store'); - Route::get('/purchase-payments/{purchase_id}/edit/{purchasePayment}', 'PurchasePaymentsController@edit')->name('purchase-payments.edit'); - Route::patch('/purchase-payments/update/{purchasePayment}', 'PurchasePaymentsController@update')->name('purchase-payments.update'); - Route::delete('/purchase-payments/destroy/{purchasePayment}', 'PurchasePaymentsController@destroy')->name('purchase-payments.destroy'); + // Purchase Payments + Route::prefix('purchase-payments')->group(function () { + Route::get('/{purchase_id}', 'PurchasePaymentsController@index')->name('purchase-payments.index'); + Route::get('/{purchase_id}/create', 'PurchasePaymentsController@create')->name('purchase-payments.create'); + Route::post('/store', 'PurchasePaymentsController@store')->name('purchase-payments.store'); + Route::get('/{purchase_id}/edit/{purchasePayment}', 'PurchasePaymentsController@edit')->name('purchase-payments.edit'); + Route::patch('/update/{purchasePayment}', 'PurchasePaymentsController@update')->name('purchase-payments.update'); + Route::delete('/destroy/{purchasePayment}', 'PurchasePaymentsController@destroy')->name('purchase-payments.destroy'); + }); + // Livewire Routes + Route::get('/purchases/create', function () { + return view('purchase::create'); + })->name('purchases.create'); }); diff --git a/Modules/Purchase/composer.json b/Modules/Purchase/composer.json index 8de2a200..14aa56bb 100644 --- a/Modules/Purchase/composer.json +++ b/Modules/Purchase/composer.json @@ -1,5 +1,5 @@ { - "name": "nwidart/purchase", + "name": "nwidart/purchase-module", "description": "", "authors": [ { @@ -9,10 +9,10 @@ ], "extra": { "laravel": { - "providers": [], - "aliases": { - - } + "providers": [ + "Modules\\Purchase\\Providers\\PurchaseServiceProvider", + "Modules\\Purchase\\Providers\\RouteServiceProvider" + ] } }, "autoload": { diff --git a/app/Http/Controllers/ProductBatchController.php b/app/Http/Controllers/ProductBatchController.php new file mode 100644 index 00000000..e638158e --- /dev/null +++ b/app/Http/Controllers/ProductBatchController.php @@ -0,0 +1,60 @@ +validated()); + return response()->json([ + 'message' => 'Stock added successfully', + 'data' => $batch + ], 201); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to add stock', + 'error' => $e->getMessage() + ], 500); + } + } + + public function getStock(Request $request, $productId, $branchId) + { + $stock = ProductBatch::getAvailableStock($productId, $branchId); + return response()->json([ + 'data' => $stock + ]); + } + + public function deductStock(Request $request) + { + $request->validate([ + 'product_id' => 'required|exists:products,id', + 'branch_id' => 'required|exists:branches,id', + 'quantity' => 'required|integer|min:1' + ]); + + try { + ProductBatch::deductStock( + $request->product_id, + $request->branch_id, + $request->quantity + ); + + return response()->json([ + 'message' => 'Stock deducted successfully' + ]); + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Failed to deduct stock', + 'error' => $e->getMessage() + ], 500); + } + } +} \ No newline at end of file diff --git a/app/Http/Requests/ProductBatchRequest.php b/app/Http/Requests/ProductBatchRequest.php new file mode 100644 index 00000000..29ba26e4 --- /dev/null +++ b/app/Http/Requests/ProductBatchRequest.php @@ -0,0 +1,47 @@ + 'required|exists:products,id', + 'branch_id' => 'required|exists:branches,id', + 'quantity' => 'required|integer|min:1', + 'purchase_price' => 'required|numeric|min:0', + ]; + + // Get product category + $product = Product::find($this->input('product_id')); + + // If product is in make-up category, require batch_code and expired_date + if ($product && $product->category === 'make up') { + $rules['batch_code'] = 'required|string'; + $rules['expired_date'] = 'required|date|after:today'; + } else { + $rules['batch_code'] = 'nullable|string'; + $rules['expired_date'] = 'nullable|date|after:today'; + } + + return $rules; + } + + public function messages() + { + return [ + 'batch_code.required' => 'Batch code is required for make-up products', + 'expired_date.required' => 'Expiry date is required for make-up products', + 'expired_date.after' => 'Expiry date must be a future date', + ]; + } +} \ No newline at end of file diff --git a/app/Livewire/Purchase/CreatePurchase.php b/app/Livewire/Purchase/CreatePurchase.php new file mode 100644 index 00000000..f3308fa8 --- /dev/null +++ b/app/Livewire/Purchase/CreatePurchase.php @@ -0,0 +1,13 @@ + 'date', + 'purchase_price' => 'decimal:2', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class); + } + + /** + * Generate a unique batch code + */ + public static function generateBatchCode(): string + { + return 'BATCH-' . strtoupper(Str::random(8)); + } + + /** + * Get available stock for a product in a specific branch using FEFO/FIFO + */ + public static function getAvailableStock(int $productId, int $branchId) + { + return self::where('product_id', $productId) + ->where('branch_id', $branchId) + ->where('quantity', '>', 0) + ->orderBy('expired_date', 'asc') + ->orderBy('created_at', 'asc') + ->get(); + } + + /** + * Deduct stock from batches using FEFO/FIFO + */ + public static function deductStock(int $productId, int $branchId, int $quantity) + { + $remainingQuantity = $quantity; + $batches = self::getAvailableStock($productId, $branchId); + $usedBatches = []; + + foreach ($batches as $batch) { + if ($remainingQuantity <= 0) break; + + $deductAmount = min($remainingQuantity, $batch->quantity); + $batch->quantity -= $deductAmount; + $batch->save(); + + $usedBatches[] = [ + 'batch_id' => $batch->id, + 'quantity' => $deductAmount, + 'purchase_price' => $batch->purchase_price, + 'expired_date' => $batch->expired_date + ]; + + $remainingQuantity -= $deductAmount; + } + + if ($remainingQuantity > 0) { + throw new \Exception('Insufficient stock available'); + } + + return $usedBatches; + } + + /** + * Add new stock to product batches + */ + public static function addStock(array $data) + { + // Generate batch code if not provided + if (!isset($data['batch_code'])) { + $data['batch_code'] = self::generateBatchCode(); + } + + return self::create($data); + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index a092e73c..7444b255 100644 --- a/composer.lock +++ b/composer.lock @@ -10290,5 +10290,5 @@ "php": "^8.1" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/database/migrations/2024_03_21_create_product_batches_table.php b/database/migrations/2024_03_21_create_product_batches_table.php new file mode 100644 index 00000000..d70989ec --- /dev/null +++ b/database/migrations/2024_03_21_create_product_batches_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->foreignId('branch_id')->constrained('branches')->onDelete('cascade'); + $table->string('batch_code')->nullable(); + $table->integer('quantity'); + $table->decimal('purchase_price', 10, 2); + $table->date('expired_date')->nullable(); + $table->timestamps(); + + // Add index for faster queries + $table->index(['product_id', 'branch_id', 'expired_date']); + }); + } + + public function down() + { + Schema::dropIfExists('product_batches'); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_03_21_remove_columns_from_products_table.php b/database/migrations/2024_03_21_remove_columns_from_products_table.php new file mode 100644 index 00000000..1b3d9cb7 --- /dev/null +++ b/database/migrations/2024_03_21_remove_columns_from_products_table.php @@ -0,0 +1,36 @@ +dropColumn([ + 'product_quantity', + 'product_cost', + 'product_price', + 'min_quantity_for_wholesale', + 'wholesale_discount_percentage', + 'product_order_tax', + 'product_tax_type' + ]); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->integer('product_quantity')->default(0); + $table->integer('product_cost')->default(0); + $table->integer('product_price')->default(0); + $table->integer('min_quantity_for_wholesale')->nullable(); + $table->integer('wholesale_discount_percentage')->nullable(); + $table->integer('product_order_tax')->default(0); + $table->tinyInteger('product_tax_type')->default(0); + }); + } +}; \ No newline at end of file diff --git a/resources/views/layouts/header.blade.php b/resources/views/layouts/header.blade.php index 7aa04376..81f583f6 100644 --- a/resources/views/layouts/header.blade.php +++ b/resources/views/layouts/header.blade.php @@ -24,7 +24,10 @@ @php - $low_quantity_products = \Modules\Product\Entities\Product::select('id', 'product_quantity', 'product_stock_alert', 'product_code')->whereColumn('product_quantity', '<=', 'product_stock_alert')->get(); + $low_quantity_products = \Modules\Product\Entities\Product::select('id', 'product_code', 'product_stock_alert') + ->withSum('batches', 'qty', 'batches_sum_qty') + ->having('batches_sum_qty', '<=', DB::raw('product_stock_alert')) + ->get(); echo $low_quantity_products->count(); @endphp diff --git a/resources/views/livewire/purchase/create-purchase.blade.php b/resources/views/livewire/purchase/create-purchase.blade.php new file mode 100644 index 00000000..15e56605 --- /dev/null +++ b/resources/views/livewire/purchase/create-purchase.blade.php @@ -0,0 +1,3 @@ +
+ {{-- The whole world belongs to you. --}} +
diff --git a/routes/api.php b/routes/api.php index bcb8b189..ecc567b5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; +use App\Http\Controllers\ProductBatchController; /* |-------------------------------------------------------------------------- @@ -17,3 +18,10 @@ use Illuminate\Support\Facades\Route; Route::middleware('auth:api')->get('/user', function (Request $request) { return $request->user(); }); + +// Product Batch Routes +Route::prefix('product-batches')->group(function () { + Route::post('/', [ProductBatchController::class, 'store']); + Route::get('/stock/{productId}/{branchId}', [ProductBatchController::class, 'getStock']); + Route::post('/deduct', [ProductBatchController::class, 'deductStock']); +});