From c3233ea6b2f5f5defe14a2941d10e6ab6373beb0 Mon Sep 17 00:00:00 2001 From: arifal Date: Tue, 24 Jun 2025 19:42:19 +0700 Subject: [PATCH] partial update transaction work with stock product --- .../Controllers/TransactionController.php | 283 +++++++-- app/Http/Controllers/WorkController.php | 22 +- .../Controllers/WorkProductController.php | 247 ++++++++ app/Models/Product.php | 22 + app/Models/Transaction.php | 34 +- app/Models/Work.php | 32 + app/Models/WorkProduct.php | 32 + app/Providers/AppServiceProvider.php | 4 +- app/Services/StockService.php | 197 ++++++ ...6_24_155937_create_work_products_table.php | 38 ++ database/seeders/WorkProductSeeder.php | 77 +++ docs/WORK_PRODUCTS_STOCK_MANAGEMENT.md | 297 +++++++++ public/css/custom-web.css | 164 +++-- public/js/pages/back/master/work-products.js | 498 +++++++++++++++ public/js/pages/back/master/work.js | 281 ++++++--- .../views/back/master/work-products.blade.php | 531 ++++++++++++++++ resources/views/back/master/work.blade.php | 315 +++++++++- resources/views/layouts/backapp.blade.php | 2 +- resources/views/transaction/index.blade.php | 580 ++++++++++++++++-- routes/web.php | 15 + 20 files changed, 3432 insertions(+), 239 deletions(-) create mode 100644 app/Http/Controllers/WorkProductController.php create mode 100644 app/Models/WorkProduct.php create mode 100644 app/Services/StockService.php create mode 100644 database/migrations/2025_06_24_155937_create_work_products_table.php create mode 100644 database/seeders/WorkProductSeeder.php create mode 100644 docs/WORK_PRODUCTS_STOCK_MANAGEMENT.md create mode 100644 public/js/pages/back/master/work-products.js create mode 100644 resources/views/back/master/work-products.blade.php diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index b14ae08..c81ca8a 100755 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -9,17 +9,30 @@ use App\Models\Stock; use App\Models\Transaction; use App\Models\User; use App\Models\Work; +use App\Services\StockService; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Exception; class TransactionController extends Controller { + protected $stockService; + + public function __construct(StockService $stockService) + { + $this->stockService = $stockService; + } + public function index() { - $work_works = Work::leftJoin('categories as c', 'c.id', '=', 'works.category_id')->select('c.name as category_name', 'works.*')->where('c.name', 'LIKE', '%kerja%')->get(); + $work_works = Work::leftJoin('categories as c', 'c.id', '=', 'works.category_id') + ->select('c.name as category_name', 'works.*') + ->where('c.name', 'LIKE', '%kerja%') + ->orderBy('works.name', 'asc') + ->get(); $wash_work = Work::leftJoin('categories as c', 'c.id', '=', 'works.category_id')->select('c.name as category_name', 'works.*')->where('c.name', 'LIKE', '%cuci%')->first(); $user_sas = User::where('role_id', 4)->where('dealer_id', Auth::user()->dealer_id)->get(); $count_transaction_users = Transaction::where("user_id", Auth::user()->id)->count(); @@ -41,7 +54,9 @@ class TransactionController extends Controller public function workcategory($category_id) { - $works = Work::where('category_id', $category_id)->get(); + $works = Work::where('category_id', $category_id) + ->orderBy('name', 'asc') + ->get(); $response = [ "message" => "get work category successfully", "data" => $works, @@ -629,14 +644,28 @@ class TransactionController extends Controller public function destroy($id) { - Transaction::find($id)->delete(); - - $response = [ - 'message' => 'Data deleted successfully', - 'status' => 200 - ]; - - return redirect()->back(); + DB::beginTransaction(); + try { + $transaction = Transaction::find($id); + + if (!$transaction) { + return redirect()->back()->withErrors(['error' => 'Transaksi tidak ditemukan']); + } + + // Restore stock before deleting transaction + $this->stockService->restoreStockForTransaction($transaction); + + // Delete the transaction + $transaction->delete(); + + DB::commit(); + + return redirect()->back()->with('success', 'Transaksi berhasil dihapus dan stock telah dikembalikan'); + + } catch (Exception $e) { + DB::rollback(); + return redirect()->back()->withErrors(['error' => 'Gagal menghapus transaksi: ' . $e->getMessage()]); + } } public function store(Request $request) @@ -645,9 +674,19 @@ class TransactionController extends Controller $request->validate([ 'work_id.*' => ['required', 'integer'], 'quantity.*' => ['required', 'integer'], - 'spk_no' => ['required', function($attribute, $value, $fail) use($request) { - $date = explode('/', $request->date); - $date = $date[2].'-'.$date[0].'-'.$date[1]; + 'spk_no' => ['required', 'string', 'min:1', function($attribute, $value, $fail) use($request) { + // Handle date format conversion safely for validation + if (strpos($request->date, '/') !== false) { + $dateParts = explode('/', $request->date); + if (count($dateParts) === 3) { + $date = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1]; + } else { + $fail('Format tanggal tidak valid'); + return; + } + } else { + $date = $request->date; + } if(!$request->work_id) { $fail('Pekerjaan harus diisi'); @@ -665,9 +704,19 @@ class TransactionController extends Controller } } }], - 'police_number' => ['required', function($attribute, $value, $fail) use($request) { - $date = explode('/', $request->date); - $date = $date[2].'-'.$date[0].'-'.$date[1]; + 'police_number' => ['required', 'string', 'min:1', function($attribute, $value, $fail) use($request) { + // Handle date format conversion safely for validation + if (strpos($request->date, '/') !== false) { + $dateParts = explode('/', $request->date); + if (count($dateParts) === 3) { + $date = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1]; + } else { + $fail('Format tanggal tidak valid'); + return; + } + } else { + $date = $request->date; + } if(!$request->work_id) { $fail('Pekerjaan harus diisi'); @@ -686,9 +735,19 @@ class TransactionController extends Controller } }], 'warranty' => ['required'], - 'date' => ['required', function($attribute, $value, $fail) use($request) { - $date = explode('/', $value); - $date = $date[2].'-'.$date[0].'-'.$date[1]; + 'date' => ['required', 'string', 'min:1', function($attribute, $value, $fail) use($request) { + // Handle date format conversion safely for validation + if (strpos($value, '/') !== false) { + $dateParts = explode('/', $value); + if (count($dateParts) === 3) { + $date = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1]; + } else { + $fail('Format tanggal tidak valid. Gunakan format MM/DD/YYYY atau YYYY-MM-DD'); + return; + } + } else { + $date = $value; + } if(!$request->work_id) { $fail('Pekerjaan harus diisi'); @@ -707,31 +766,117 @@ class TransactionController extends Controller } }], 'category' => ['required'], - 'user_sa_id' => ['required', 'integer'], + 'user_sa_id' => ['required', 'integer', 'exists:users,id'], + ], [ + 'spk_no.required' => 'No. SPK harus diisi', + 'spk_no.min' => 'No. SPK tidak boleh kosong', + 'police_number.required' => 'No. Polisi harus diisi', + 'police_number.min' => 'No. Polisi tidak boleh kosong', + 'date.required' => 'Tanggal Pekerjaan harus diisi', + 'date.min' => 'Tanggal Pekerjaan tidak boleh kosong', + 'user_sa_id.required' => 'Service Advisor harus dipilih', + 'user_sa_id.exists' => 'Service Advisor yang dipilih tidak valid', + 'work_id.*.required' => 'Pekerjaan harus dipilih', + 'quantity.*.required' => 'Quantity harus diisi', ]); - $request['date'] = explode('/', $request->date); - $request['date'] = $request['date'][2].'-'.$request['date'][0].'-'.$request['date'][1]; - - $data = []; - for($i = 0; $i < count($request->work_id); $i++) { - $data[] = [ - "user_id" => $request->mechanic_id, - "dealer_id" => $request->dealer_id, - "form" => $request->form, - "work_id" => $request->work_id[$i], - "qty" => $request->quantity[$i], - "spk" => $request->spk_no, - "police_number" => $request->police_number, - "warranty" => $request->warranty, - "user_sa_id" => $request->user_sa_id, - "date" => $request->date, - "created_at" => date('Y-m-d H:i:s') - ]; + // Handle date format conversion safely + $dateValue = $request->date; + if (strpos($dateValue, '/') !== false) { + // If date is in MM/DD/YYYY format, convert to Y-m-d + $dateParts = explode('/', $dateValue); + if (count($dateParts) === 3) { + $request['date'] = $dateParts[2].'-'.$dateParts[0].'-'.$dateParts[1]; + } else { + // Invalid date format, use as is + $request['date'] = $dateValue; + } + } else { + // Date is already in Y-m-d format or other format, use as is + $request['date'] = $dateValue; } - Transaction::insert($data); - return redirect()->back()->with('success', 'Berhasil input pekerjaan'); + // Check stock availability for all works before creating transactions + $stockErrors = []; + for($i = 0; $i < count($request->work_id); $i++) { + $stockCheck = $this->stockService->checkStockAvailability( + $request->work_id[$i], + $request->dealer_id, + $request->quantity[$i] + ); + + if (!$stockCheck['available']) { + $work = Work::find($request->work_id[$i]); + $stockErrors[] = "Pekerjaan '{$work->name}': {$stockCheck['message']}"; + + // Add detailed stock information + if (!empty($stockCheck['details'])) { + foreach ($stockCheck['details'] as $detail) { + if (!$detail['is_available']) { + $stockErrors[] = "- {$detail['product_name']}: Dibutuhkan {$detail['required_quantity']}, Tersedia {$detail['available_stock']}"; + } + } + } + } + } + + // If there are stock errors, return with error messages + if (!empty($stockErrors)) { + return redirect()->back() + ->withErrors(['stock' => implode('
', $stockErrors)]) + ->withInput(); + } + + DB::beginTransaction(); + try { + $transactions = []; + $data = []; + + // Create transaction records + for($i = 0; $i < count($request->work_id); $i++) { + $transactionData = [ + "user_id" => $request->mechanic_id, + "dealer_id" => $request->dealer_id, + "form" => $request->form, + "work_id" => $request->work_id[$i], + "qty" => $request->quantity[$i], + "spk" => $request->spk_no, + "police_number" => $request->police_number, + "warranty" => $request->warranty, + "user_sa_id" => $request->user_sa_id, + "date" => $request->date, + "status" => 'completed', // Mark as completed to trigger stock reduction + "created_at" => date('Y-m-d H:i:s'), + "updated_at" => date('Y-m-d H:i:s') + ]; + + $data[] = $transactionData; + } + + // Insert all transactions + Transaction::insert($data); + + // Get the created transactions for stock reduction + $createdTransactions = Transaction::where('spk', $request->spk_no) + ->where('police_number', $request->police_number) + ->where('date', $request->date) + ->where('dealer_id', $request->dealer_id) + ->get(); + + // Reduce stock for each transaction + foreach ($createdTransactions as $transaction) { + $this->stockService->reduceStockForTransaction($transaction); + } + + DB::commit(); + return redirect()->back()->with('success', 'Berhasil input pekerjaan dan stock telah dikurangi otomatis'); + + } catch (Exception $e) { + DB::rollback(); + return redirect()->back() + ->withErrors(['error' => 'Gagal menyimpan transaksi: ' . $e->getMessage()]) + ->withInput(); + } } public function edit($id) @@ -764,4 +909,62 @@ class TransactionController extends Controller return response()->json($response); } + + /** + * Check stock availability for work at dealer + */ + public function checkStockAvailability(Request $request) + { + $request->validate([ + 'work_id' => 'required|exists:works,id', + 'dealer_id' => 'required|exists:dealers,id', + 'quantity' => 'required|integer|min:1' + ]); + + try { + $availability = $this->stockService->checkStockAvailability( + $request->work_id, + $request->dealer_id, + $request->quantity + ); + + return response()->json([ + 'status' => 200, + 'data' => $availability + ]); + } catch (Exception $e) { + return response()->json([ + 'status' => 500, + 'message' => 'Error checking stock: ' . $e->getMessage() + ], 500); + } + } + + /** + * Get stock prediction for work + */ + public function getStockPrediction(Request $request) + { + $request->validate([ + 'work_id' => 'required|exists:works,id', + 'quantity' => 'required|integer|min:1' + ]); + + try { + $prediction = $this->stockService->getStockUsagePrediction( + $request->work_id, + $request->quantity + ); + + return response()->json([ + 'status' => 200, + 'data' => $prediction + ]); + } catch (Exception $e) { + return response()->json([ + 'status' => 500, + 'message' => 'Error getting prediction: ' . $e->getMessage() + ], 500); + } + } } diff --git a/app/Http/Controllers/WorkController.php b/app/Http/Controllers/WorkController.php index 4b84122..104cfb0 100755 --- a/app/Http/Controllers/WorkController.php +++ b/app/Http/Controllers/WorkController.php @@ -26,16 +26,28 @@ class WorkController extends Controller $data = DB::table('works as w')->leftJoin('categories as c', 'c.id', '=', 'w.category_id')->select('w.shortname as shortname', 'w.id as work_id', 'w.name as name', 'w.desc as desc', 'c.name as category_name', 'w.category_id as category_id'); return DataTables::of($data)->addIndexColumn() ->addColumn('action', function($row) use ($menu) { - $btn = ''; + $btn = '
'; - if(Auth::user()->can('delete', $menu)) { - $btn .= ''; + // Products Management Button + if(Gate::allows('view', $menu)) { + $btn .= ' + Produk + '; } - if(Auth::user()->can('update', $menu)) { - $btn .= ''; + if(Gate::allows('update', $menu)) { + $btn .= ''; } + if(Gate::allows('delete', $menu)) { + $btn .= ''; + } + + $btn .= '
'; return $btn; }) ->rawColumns(['action']) diff --git a/app/Http/Controllers/WorkProductController.php b/app/Http/Controllers/WorkProductController.php new file mode 100644 index 0000000..903885d --- /dev/null +++ b/app/Http/Controllers/WorkProductController.php @@ -0,0 +1,247 @@ +stockService = $stockService; + } + + /** + * Display work products for a specific work + */ + public function index(Request $request, $workId) + { + $menu = Menu::where('link', 'work.index')->first(); + abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User'); + + $work = Work::with('category')->findOrFail($workId); + + if ($request->ajax()) { + Log::info('Work products index AJAX request for work ID: ' . $workId); + + $workProducts = WorkProduct::with(['product', 'product.category']) + ->where('work_id', $workId) + ->get(); + + Log::info('Found ' . $workProducts->count() . ' work products'); + + return DataTables::of($workProducts) + ->addIndexColumn() + ->addColumn('product_name', function($row) { + return $row->product->name; + }) + ->addColumn('product_code', function($row) { + return $row->product->code; + }) + ->addColumn('product_category', function($row) { + return $row->product->category ? $row->product->category->name : '-'; + }) + ->addColumn('unit', function($row) { + return $row->product->unit; + }) + ->addColumn('quantity_required', function($row) { + return number_format($row->quantity_required, 2); + }) + ->addColumn('action', function($row) use ($menu) { + $btn = '
'; + + if(Gate::allows('update', $menu)) { + $btn .= ''; + } + + if(Gate::allows('delete', $menu)) { + $btn .= ''; + } + + $btn .= '
'; + return $btn; + }) + ->rawColumns(['action']) + ->make(true); + } + + $products = Product::where('active', true)->with('category')->get(); + + return view('back.master.work-products', compact('work', 'products')); + } + + /** + * Store work product relationship + */ + public function store(Request $request) + { + $menu = Menu::where('link', 'work.index')->first(); + abort_if(Gate::denies('create', $menu), 403, 'Unauthorized User'); + + $request->validate([ + 'work_id' => 'required|exists:works,id', + 'product_id' => 'required|exists:products,id', + 'quantity_required' => 'required|numeric|min:0.01', + 'notes' => 'nullable|string' + ]); + + // Check if combination already exists + $exists = WorkProduct::where('work_id', $request->work_id) + ->where('product_id', $request->product_id) + ->exists(); + + if ($exists) { + return response()->json([ + 'status' => 422, + 'message' => 'Produk sudah ditambahkan ke pekerjaan ini' + ], 422); + } + + WorkProduct::create($request->all()); + + return response()->json([ + 'status' => 200, + 'message' => 'Produk berhasil ditambahkan ke pekerjaan' + ]); + } + + /** + * Show work product for editing + */ + public function show($workId, $workProductId) + { + $menu = Menu::where('link', 'work.index')->first(); + abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User'); + + try { + $workProduct = WorkProduct::with(['work', 'product', 'product.category']) + ->where('work_id', $workId) + ->where('id', $workProductId) + ->firstOrFail(); + + return response()->json([ + 'status' => 200, + 'data' => $workProduct + ]); + } catch (\Exception $e) { + Log::error('Error fetching work product: ' . $e->getMessage()); + return response()->json([ + 'status' => 404, + 'message' => 'Work product tidak ditemukan' + ], 404); + } + } + + /** + * Update work product relationship + */ + public function update(Request $request, $workId, $workProductId) + { + $menu = Menu::where('link', 'work.index')->first(); + abort_if(Gate::denies('update', $menu), 403, 'Unauthorized User'); + + $request->validate([ + 'quantity_required' => 'required|numeric|min:0.01', + 'notes' => 'nullable|string' + ]); + + try { + $workProduct = WorkProduct::where('work_id', $workId) + ->where('id', $workProductId) + ->firstOrFail(); + + $workProduct->update($request->only(['quantity_required', 'notes'])); + + return response()->json([ + 'status' => 200, + 'message' => 'Data produk pekerjaan berhasil diupdate' + ]); + } catch (\Exception $e) { + Log::error('Error updating work product: ' . $e->getMessage()); + return response()->json([ + 'status' => 404, + 'message' => 'Work product tidak ditemukan' + ], 404); + } + } + + /** + * Remove work product relationship + */ + public function destroy($workId, $workProductId) + { + $menu = Menu::where('link', 'work.index')->first(); + abort_if(Gate::denies('delete', $menu), 403, 'Unauthorized User'); + + try { + $workProduct = WorkProduct::where('work_id', $workId) + ->where('id', $workProductId) + ->firstOrFail(); + + $workProduct->delete(); + + return response()->json([ + 'status' => 200, + 'message' => 'Produk berhasil dihapus dari pekerjaan' + ]); + } catch (\Exception $e) { + Log::error('Error deleting work product: ' . $e->getMessage()); + return response()->json([ + 'status' => 404, + 'message' => 'Work product tidak ditemukan' + ], 404); + } + } + + /** + * Get stock prediction for work + */ + public function stockPrediction(Request $request, $workId) + { + $quantity = $request->get('quantity', 1); + $prediction = $this->stockService->getStockUsagePrediction($workId, $quantity); + + return response()->json([ + 'status' => 200, + 'data' => $prediction + ]); + } + + /** + * Check stock availability for work at specific dealer + */ + public function checkStock(Request $request) + { + $request->validate([ + 'work_id' => 'required|exists:works,id', + 'dealer_id' => 'required|exists:dealers,id', + 'quantity' => 'required|integer|min:1' + ]); + + $availability = $this->stockService->checkStockAvailability( + $request->work_id, + $request->dealer_id, + $request->quantity + ); + + return response()->json([ + 'status' => 200, + 'data' => $availability + ]); + } +} \ No newline at end of file diff --git a/app/Models/Product.php b/app/Models/Product.php index de41a74..6c519f6 100755 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -47,4 +47,26 @@ class Product extends Model { return $this->stocks()->where('dealer_id', $dealerId)->first()?->quantity ?? 0; } + + /** + * Get all works that require this product + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function works() + { + return $this->belongsToMany(Work::class, 'work_products') + ->withPivot('quantity_required', 'notes') + ->withTimestamps(); + } + + /** + * Get work products pivot records + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function workProducts() + { + return $this->hasMany(WorkProduct::class); + } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 678c106..f06f568 100755 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -16,10 +16,40 @@ class Transaction extends Model /** * Get the work associated with the Transaction * - * @return \Illuminate\Database\Eloquent\Relations\HasOne + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function work() { - return $this->hasOne(Work::class, 'id', 'work_id'); + return $this->belongsTo(Work::class, 'work_id', 'id'); + } + + /** + * Get the dealer associated with the Transaction + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function dealer() + { + return $this->belongsTo(Dealer::class); + } + + /** + * Get the user who created the transaction + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * Get the SA user associated with the transaction + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function userSa() + { + return $this->belongsTo(User::class, 'user_sa_id'); } } diff --git a/app/Models/Work.php b/app/Models/Work.php index fe59061..3eb6696 100755 --- a/app/Models/Work.php +++ b/app/Models/Work.php @@ -22,4 +22,36 @@ class Work extends Model { return $this->hasMany(Transaction::class, 'work_id', 'id'); } + + /** + * Get all products required for this work + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function products() + { + return $this->belongsToMany(Product::class, 'work_products') + ->withPivot('quantity_required', 'notes') + ->withTimestamps(); + } + + /** + * Get work products pivot records + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function workProducts() + { + return $this->hasMany(WorkProduct::class); + } + + /** + * Get the category associated with the Work + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function category() + { + return $this->belongsTo(Category::class); + } } diff --git a/app/Models/WorkProduct.php b/app/Models/WorkProduct.php new file mode 100644 index 0000000..09e170e --- /dev/null +++ b/app/Models/WorkProduct.php @@ -0,0 +1,32 @@ + 'decimal:2' + ]; + + public function work() + { + return $this->belongsTo(Work::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7464ec5..4551eb4 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,7 +17,9 @@ class AppServiceProvider extends ServiceProvider */ public function register() { - // + $this->app->singleton(\App\Services\StockService::class, function ($app) { + return new \App\Services\StockService(); + }); } /** diff --git a/app/Services/StockService.php b/app/Services/StockService.php new file mode 100644 index 0000000..cd68892 --- /dev/null +++ b/app/Services/StockService.php @@ -0,0 +1,197 @@ +find($workId); + + if (!$work) { + return [ + 'available' => false, + 'message' => 'Pekerjaan tidak ditemukan', + 'details' => [] + ]; + } + + $stockDetails = []; + $allAvailable = true; + + foreach ($work->products as $product) { + $requiredQuantity = $product->pivot->quantity_required * $workQuantity; + $availableStock = $product->getStockByDealer($dealerId); + + $isAvailable = $availableStock >= $requiredQuantity; + if (!$isAvailable) { + $allAvailable = false; + } + + $stockDetails[] = [ + 'product_id' => $product->id, + 'product_name' => $product->name, + 'required_quantity' => $requiredQuantity, + 'available_stock' => $availableStock, + 'is_available' => $isAvailable + ]; + } + + return [ + 'available' => $allAvailable, + 'message' => $allAvailable ? 'Stock tersedia' : 'Stock tidak mencukupi', + 'details' => $stockDetails + ]; + } + + /** + * Reduce stock when work transaction is completed + * + * @param Transaction $transaction + * @return bool + * @throws Exception + */ + public function reduceStockForTransaction(Transaction $transaction) + { + return DB::transaction(function () use ($transaction) { + $work = $transaction->work; + + if (!$work) { + throw new Exception('Work not found for transaction'); + } + + $work->load('products'); + + if ($work->products->isEmpty()) { + // No products required for this work, return true + return true; + } + + foreach ($work->products as $product) { + $requiredQuantity = $product->pivot->quantity_required * $transaction->qty; + + $stock = Stock::where('product_id', $product->id) + ->where('dealer_id', $transaction->dealer_id) + ->first(); + + if (!$stock) { + throw new Exception("Stock not found for product {$product->name} at dealer"); + } + + if ($stock->quantity < $requiredQuantity) { + throw new Exception("Insufficient stock for product {$product->name}. Required: {$requiredQuantity}, Available: {$stock->quantity}"); + } + + // Reduce stock + $newQuantity = $stock->quantity - $requiredQuantity; + $stock->updateStock( + $newQuantity, + $transaction, + "Stock reduced for work: {$work->name} (Transaction #{$transaction->id})" + ); + } + + return true; + }); + } + + /** + * Restore stock when work transaction is cancelled/reversed + * + * @param Transaction $transaction + * @return bool + * @throws Exception + */ + public function restoreStockForTransaction(Transaction $transaction) + { + return DB::transaction(function () use ($transaction) { + $work = $transaction->work; + + if (!$work) { + throw new Exception('Work not found for transaction'); + } + + $work->load('products'); + + if ($work->products->isEmpty()) { + return true; + } + + foreach ($work->products as $product) { + $restoreQuantity = $product->pivot->quantity_required * $transaction->qty; + + $stock = Stock::where('product_id', $product->id) + ->where('dealer_id', $transaction->dealer_id) + ->first(); + + if (!$stock) { + // Create new stock record if doesn't exist + $stock = Stock::create([ + 'product_id' => $product->id, + 'dealer_id' => $transaction->dealer_id, + 'quantity' => 0 + ]); + } + + // Restore stock + $newQuantity = $stock->quantity + $restoreQuantity; + $stock->updateStock( + $newQuantity, + $transaction, + "Stock restored from cancelled work: {$work->name} (Transaction #{$transaction->id})" + ); + } + + return true; + }); + } + + /** + * Get stock usage prediction for a work + * + * @param int $workId + * @param int $quantity + * @return array + */ + public function getStockUsagePrediction($workId, $quantity = 1) + { + $work = Work::with('products')->find($workId); + + if (!$work) { + return []; + } + + $predictions = []; + + foreach ($work->products as $product) { + $totalRequired = $product->pivot->quantity_required * $quantity; + + $predictions[] = [ + 'product_id' => $product->id, + 'product_name' => $product->name, + 'product_code' => $product->code, + 'unit' => $product->unit, + 'quantity_per_work' => $product->pivot->quantity_required, + 'total_quantity_needed' => $totalRequired, + 'notes' => $product->pivot->notes + ]; + } + + return $predictions; + } +} \ No newline at end of file diff --git a/database/migrations/2025_06_24_155937_create_work_products_table.php b/database/migrations/2025_06_24_155937_create_work_products_table.php new file mode 100644 index 0000000..e484d97 --- /dev/null +++ b/database/migrations/2025_06_24_155937_create_work_products_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('work_id')->constrained('works')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + $table->decimal('quantity_required', 10, 2)->default(1); + $table->text('notes')->nullable(); + $table->timestamps(); + + // Prevent duplicate work-product combinations + $table->unique(['work_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('work_products'); + } +} diff --git a/database/seeders/WorkProductSeeder.php b/database/seeders/WorkProductSeeder.php new file mode 100644 index 0000000..9a34886 --- /dev/null +++ b/database/seeders/WorkProductSeeder.php @@ -0,0 +1,77 @@ +get(); + $products = Product::where('active', true)->take(5)->get(); + + if ($works->isEmpty() || $products->isEmpty()) { + $this->command->info('Tidak ada data Work atau Product untuk seeding. Silakan buat data tersebut terlebih dahulu.'); + return; + } + + // Sample work-product relationships + $workProductData = [ + [ + 'work_id' => $works->first()->id, + 'product_id' => $products->first()->id, + 'quantity_required' => 2.0, + 'notes' => 'Digunakan untuk pembersihan awal' + ], + [ + 'work_id' => $works->first()->id, + 'product_id' => $products->skip(1)->first()->id, + 'quantity_required' => 1.0, + 'notes' => 'Untuk finishing' + ], + [ + 'work_id' => $works->skip(1)->first()->id, + 'product_id' => $products->skip(2)->first()->id, + 'quantity_required' => 3.0, + 'notes' => 'Komponen utama' + ], + [ + 'work_id' => $works->skip(1)->first()->id, + 'product_id' => $products->skip(3)->first()->id, + 'quantity_required' => 0.5, + 'notes' => 'Pelumas tambahan' + ], + [ + 'work_id' => $works->skip(2)->first()->id, + 'product_id' => $products->skip(4)->first()->id, + 'quantity_required' => 1.0, + 'notes' => 'Standard usage' + ] + ]; + + foreach ($workProductData as $data) { + WorkProduct::firstOrCreate( + [ + 'work_id' => $data['work_id'], + 'product_id' => $data['product_id'] + ], + [ + 'quantity_required' => $data['quantity_required'], + 'notes' => $data['notes'] + ] + ); + } + + $this->command->info('Work Product relationships seeded successfully!'); + } +} \ No newline at end of file diff --git a/docs/WORK_PRODUCTS_STOCK_MANAGEMENT.md b/docs/WORK_PRODUCTS_STOCK_MANAGEMENT.md new file mode 100644 index 0000000..de80691 --- /dev/null +++ b/docs/WORK_PRODUCTS_STOCK_MANAGEMENT.md @@ -0,0 +1,297 @@ +# Work Products & Stock Management System + +## Overview + +Sistem ini memungkinkan setiap pekerjaan (work) memiliki relasi dengan banyak produk (products) dan otomatis mengurangi stock di dealer ketika transaksi pekerjaan dilakukan. + +## Fitur Utama + +### 1. Work Products Management + +- Setiap pekerjaan dapat dikonfigurasi untuk memerlukan produk tertentu +- Admin dapat mengatur jumlah (quantity) produk yang dibutuhkan per pekerjaan +- Mendukung catatan/notes untuk setiap produk + +### 2. Automatic Stock Reduction + +- Stock otomatis dikurangi ketika transaksi pekerjaan dibuat +- Validasi stock tersedia sebelum transaksi disimpan +- Stock dikembalikan ketika transaksi dihapus + +### 3. Stock Validation & Warning + +- Real-time checking stock availability saat memilih pekerjaan +- Warning ketika stock tidak mencukupi +- Konfirmasi user sebelum melanjutkan dengan stock negatif + +### 4. Stock Prediction + +- Melihat prediksi penggunaan stock untuk pekerjaan tertentu +- Kalkulasi berdasarkan quantity pekerjaan yang akan dilakukan + +## Database Schema + +### Tabel `work_products` + +```sql +CREATE TABLE work_products ( + id BIGINT PRIMARY KEY, + work_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity_required DECIMAL(10,2) DEFAULT 1.00, + notes TEXT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP, + UNIQUE KEY unique_work_product (work_id, product_id), + FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE +); +``` + +### Model Relationships + +#### Work Model + +```php +// Relasi many-to-many dengan Product +public function products() +{ + return $this->belongsToMany(Product::class, 'work_products') + ->withPivot('quantity_required', 'notes') + ->withTimestamps(); +} + +// Relasi one-to-many dengan WorkProduct +public function workProducts() +{ + return $this->hasMany(WorkProduct::class); +} +``` + +#### Product Model + +```php +// Relasi many-to-many dengan Work +public function works() +{ + return $this->belongsToMany(Work::class, 'work_products') + ->withPivot('quantity_required', 'notes') + ->withTimestamps(); +} +``` + +## API Endpoints + +### Work Products Management + +``` +GET /admin/work/{work}/products - List work products +POST /admin/work/{work}/products - Add product to work +GET /admin/work/{work}/products/{id} - Show work product +PUT /admin/work/{work}/products/{id} - Update work product +DELETE /admin/work/{work}/products/{id} - Remove product from work +``` + +### Stock Operations + +``` +POST /transaction/check-stock - Check stock availability +GET /transaction/stock-prediction - Get stock usage prediction +GET /admin/work/{work}/stock-prediction - Get work stock prediction +``` + +## StockService Methods + +### `checkStockAvailability($workId, $dealerId, $workQuantity)` + +Mengecek apakah dealer memiliki stock yang cukup untuk pekerjaan tertentu. + +**Parameters:** + +- `$workId`: ID pekerjaan +- `$dealerId`: ID dealer +- `$workQuantity`: Jumlah pekerjaan yang akan dilakukan + +**Returns:** + +```php +[ + 'available' => bool, + 'message' => string, + 'details' => [ + [ + 'product_id' => int, + 'product_name' => string, + 'required_quantity' => float, + 'available_stock' => float, + 'is_available' => bool + ] + ] +] +``` + +### `reduceStockForTransaction($transaction)` + +Mengurangi stock otomatis berdasarkan transaksi pekerjaan. + +### `restoreStockForTransaction($transaction)` + +Mengembalikan stock ketika transaksi dibatalkan/dihapus. + +### `getStockUsagePrediction($workId, $quantity)` + +Mendapatkan prediksi penggunaan stock untuk pekerjaan. + +## User Interface + +### 1. Work Products Management + +- Akses melalui: **Admin Panel > Master > Pekerjaan > [Pilih Pekerjaan] > Tombol "Produk"** +- Fitur: + - Tambah/edit/hapus produk yang diperlukan + - Set quantity required per produk + - Tambah catatan untuk produk + - Preview prediksi penggunaan stock + +### 2. Transaction Form dengan Stock Warning + +- Real-time stock checking saat memilih pekerjaan +- Warning visual ketika stock tidak mencukupi +- Konfirmasi sebelum submit dengan stock negatif + +### 3. Stock Prediction Modal + +- Kalkulasi total produk yang dibutuhkan +- Informasi per produk dengan quantity dan satuan + +## Usage Examples + +### 1. Mengatur Produk untuk Pekerjaan "Service Rutin" + +1. Masuk ke Admin Panel > Master > Pekerjaan +2. Klik tombol "Produk" pada pekerjaan "Service Rutin" +3. Klik "Tambah Produk" +4. Pilih produk "Oli Mesin", set quantity 4, notes "4 liter untuk ganti oli" +5. Tambah produk "Filter Oli", set quantity 1, notes "Filter standar" +6. Simpan + +### 2. Membuat Transaksi dengan Stock Warning + +1. Pada form transaksi, pilih pekerjaan "Service Rutin" +2. Set quantity 2 (untuk 2 kendaraan) +3. Sistem akan menampilkan warning jika stock tidak cukup: + - Oli Mesin: Butuh 8 liter, Tersedia 5 liter + - Filter Oli: Butuh 2 unit, Tersedia 3 unit +4. User dapat memilih untuk melanjutkan atau membatalkan + +### 3. Melihat Prediksi Stock + +1. Di halaman Work Products, klik "Prediksi Stock" +2. Set jumlah pekerjaan (misal: 5) +3. Sistem menampilkan: + - Oli Mesin: 4 liter/pekerjaan × 5 = 20 liter total + - Filter Oli: 1 unit/pekerjaan × 5 = 5 unit total + +## Stock Flow Process + +### Saat Transaksi Dibuat: + +1. User memilih pekerjaan dan quantity +2. Sistem check stock availability +3. Jika stock tidak cukup, tampilkan warning +4. User konfirmasi untuk melanjutkan +5. Transaksi disimpan dengan status 'completed' +6. Stock otomatis dikurangi sesuai konfigurasi work products + +### Saat Transaksi Dihapus: + +1. Sistem ambil data transaksi +2. Kembalikan stock sesuai dengan produk yang digunakan +3. Catat dalam stock log +4. Hapus transaksi + +## Error Handling + +### Stock Tidak Mencukupi: + +- Tampilkan warning dengan detail produk +- Izinkan user untuk melanjutkan dengan konfirmasi +- Stock boleh menjadi negatif (business rule) + +### Product Tidak Dikonfigurasi: + +- Jika pekerjaan belum dikonfigurasi produknya, tidak ada pengurangan stock +- Transaksi tetap bisa dibuat normal + +### Database Transaction: + +- Semua operasi stock menggunakan database transaction +- Rollback otomatis jika ada error + +## Best Practices + +### 1. Konfigurasi Work Products + +- Set quantity required yang akurat +- Gunakan notes untuk informasi tambahan +- Review berkala konfigurasi produk + +### 2. Stock Management + +- Monitor stock levels secara berkala +- Set minimum stock alerts +- Koordinasi dengan procurement team + +### 3. Training User + +- Berikan training tentang stock warnings +- Edukasi tentang impact stock negatif +- Prosedur escalation jika stock habis + +## Troubleshooting + +### Stock Tidak Berkurang Otomatis: + +1. Cek konfigurasi work products +2. Pastikan produk memiliki stock record di dealer +3. Check error logs + +### Error Saat Submit Transaksi: + +1. Refresh halaman dan coba lagi +2. Check koneksi internet +3. Contact admin jika masih error + +### Stock Calculation Salah: + +1. Review konfigurasi quantity di work products +2. Check apakah ada duplikasi produk +3. Verify stock log untuk audit trail + +## Monitoring & Reporting + +### Stock Logs + +Semua perubahan stock tercatat dalam `stock_logs` table dengan informasi: + +- Source transaction +- Previous quantity +- New quantity +- Change amount +- Timestamp +- User who made the change + +### Reports Available + +- Stock usage by work type +- Stock movement history +- Negative stock alerts +- Product consumption analysis + +## Future Enhancements + +1. **Automated Stock Alerts**: Email notifications ketika stock di bawah minimum +2. **Batch Operations**: Update multiple work products sekaligus +3. **Stock Forecasting**: Prediksi kebutuhan stock berdasarkan historical data +4. **Mobile Interface**: Mobile-friendly interface untuk stock checking +5. **Integration**: Integration dengan sistem procurement/inventory external diff --git a/public/css/custom-web.css b/public/css/custom-web.css index f642383..30bd4f0 100755 --- a/public/css/custom-web.css +++ b/public/css/custom-web.css @@ -1,67 +1,71 @@ .uppercaseFontCss input, .uppercaseFontCss textarea, -.uppercaseFontCss .filter-option-inner-inner{ +.uppercaseFontCss .filter-option-inner-inner { text-transform: uppercase; } .nonecaseFontCss input, .nonecaseFontCss textarea, -.nonecaseFontCss .filter-option-inner-inner{ +.nonecaseFontCss .filter-option-inner-inner { text-transform: none !important; } -.wrapperbtnToAdmin{ +.wrapperbtnToAdmin { height: 100%; padding-top: 16px; } -.blockTtd{ +.blockTtd { text-align: center; } -.sectionPrint{ +.sectionPrint { padding: 20px; } -.tablePartBengkel, .tableJasaBengkel, .tablePembayaran, .faktur-service-info, .kerja-service-info{ +.tablePartBengkel, +.tableJasaBengkel, +.tablePembayaran, +.faktur-service-info, +.kerja-service-info { width: 100%; } -.tandaTanganPrint{ +.tandaTanganPrint { border-bottom: 1px solid #ccc; padding-top: 10px; - padding-bottom: : 2px; + padding-bottom: 2px; min-height: 50px; } -.headTablePrint{ +.headTablePrint { border-bottom: 1px dashed #ccc; } -.infoKendaraanDanMekanik{ +.infoKendaraanDanMekanik { border-bottom: 1px solid #ccc; } -.headerPrint{ +.headerPrint { border-bottom: 1px solid #ccc; } -.faktur-service-info td{ +.faktur-service-info td { padding-right: 10px; } -.kerja-service-info td{ +.kerja-service-info td { padding-right: 10px; } -.inputUppercase{ - text-transform:uppercase +.inputUppercase { + text-transform: uppercase; } -.btnAddDadakan{ +.btnAddDadakan { /*position: absolute;*/ /*right: -4px;*/ margin-left: 24px; } -.product-item-harga-subtotal{ - position: absolute; +.product-item-harga-subtotal { + position: absolute; right: 0; } -.product-item-harga-total{ +.product-item-harga-total { position: relative; font-weight: bold; } -.text-harga-total{ - position: absolute; +.text-harga-total { + position: absolute; right: 0; } .product-item { @@ -69,12 +73,12 @@ position: relative; } .product-item:last-of-type { - margin-bottom: 0px; + margin-bottom: 0px; } -.label-repeater-hd{ +.label-repeater-hd { display: none !important; } -.label-repeater-hd:first-of-type{ +.label-repeater-hd:first-of-type { display: block; } @@ -84,7 +88,7 @@ .label.label-lg { height: 24px; width: 24px; - font-size: .9rem; + font-size: 0.9rem; width: 30px; height: 30px; padding: 2px 15px; @@ -93,7 +97,7 @@ margin-right: 5px; } .label.label-rounded { - border-radius: border-radius: 4px; + border-radius: 4px; } .label.label-waiting { color: #fff; @@ -127,29 +131,29 @@ color: #fff; background-color: #3699ff; } -.legendHeader{ - margin-top: 54px !important; +.legendHeader { + margin-top: 54px !important; padding-top: 17px; } .legendBlock { - display: inline-block; - margin-right: 15px; + display: inline-block; + margin-right: 15px; } -#content-order-list{ - margin-top: 40px; +#content-order-list { + margin-top: 40px; } -#content-order-list .kt-portlet__body{ - background: #fff; +#content-order-list .kt-portlet__body { + background: #fff; color: #646c9a; border-radius: 0px; } -.btn{ - cursor: pointer !important; +.btn { + cursor: pointer !important; } -.wrapText{ - white-space: pre-line; +.wrapText { + white-space: pre-line; } -.blockTrxNote{ +.blockTrxNote { border: 1px solid #ddd; padding: 10px; margin-left: 10px; @@ -158,7 +162,83 @@ } @media print { - body, html { - font-family: 'Verdana'; + body, + html { + font-family: "Verdana"; } -} \ No newline at end of file +} + +/* SweetAlert Icon Centering - Global */ +.swal2-icon { + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin: 0 auto !important; +} + +.swal2-icon .swal2-icon-content { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + height: 100% !important; +} + +.swal2-popup { + text-align: center !important; +} + +.swal2-title { + text-align: center !important; +} + +.swal2-content { + text-align: center !important; +} + +.swal2-actions { + justify-content: center !important; +} + +/* Additional SweetAlert styling for better appearance */ +.swal2-popup { + border-radius: 0.5rem !important; + font-family: inherit !important; +} + +.swal2-title { + font-size: 1.5rem !important; + font-weight: 600 !important; + color: #495057 !important; +} + +.swal2-content { + font-size: 1rem !important; + color: #6c757d !important; +} + +.swal2-actions .swal2-confirm { + background-color: #007bff !important; + border-color: #007bff !important; + border-radius: 0.375rem !important; + padding: 0.5rem 1.5rem !important; + font-weight: 500 !important; +} + +.swal2-actions .swal2-cancel { + background-color: #6c757d !important; + border-color: #6c757d !important; + border-radius: 0.375rem !important; + padding: 0.5rem 1.5rem !important; + font-weight: 500 !important; +} + +.swal2-actions .swal2-confirm:hover { + background-color: #0056b3 !important; + border-color: #0056b3 !important; +} + +.swal2-actions .swal2-cancel:hover { + background-color: #545b62 !important; + border-color: #545b62 !important; +} diff --git a/public/js/pages/back/master/work-products.js b/public/js/pages/back/master/work-products.js new file mode 100644 index 0000000..715e1fe --- /dev/null +++ b/public/js/pages/back/master/work-products.js @@ -0,0 +1,498 @@ +// Global variables +let workId, + indexUrl, + storeUrl, + stockPredictionUrl, + csrfToken, + table, + baseUrl, + showUrlTemplate, + updateUrlTemplate, + destroyUrlTemplate; + +$(document).ready(function () { + // Get URLs from hidden inputs + workId = $('input[name="work_id"]').val(); + indexUrl = $('input[name="index_url"]').val(); + storeUrl = $('input[name="store_url"]').val(); + stockPredictionUrl = $('input[name="stock_prediction_url"]').val(); + csrfToken = $('input[name="csrf_token"]').val(); + baseUrl = $('input[name="base_url"]').val(); + showUrlTemplate = $('input[name="show_url_template"]').val(); + updateUrlTemplate = $('input[name="update_url_template"]').val(); + destroyUrlTemplate = $('input[name="destroy_url_template"]').val(); + + console.log("Initialized with:", { + workId, + indexUrl, + storeUrl, + stockPredictionUrl, + csrfToken: csrfToken ? "Set" : "Not set", + baseUrl, + showUrlTemplate, + updateUrlTemplate, + destroyUrlTemplate, + }); + + // Set up CSRF token for all AJAX requests + $.ajaxSetup({ + headers: { + "X-CSRF-TOKEN": csrfToken, + }, + }); + + // Initialize Select2 + $("#product_id").select2({ + placeholder: "-- Pilih Produk --", + allowClear: true, + width: "100%", + dropdownParent: $("#workProductModal"), + language: { + noResults: function () { + return "Tidak ada hasil yang ditemukan"; + }, + searching: function () { + return "Mencari..."; + }, + }, + }); + + table = $("#workProductsTable").DataTable({ + processing: true, + serverSide: true, + ajax: { + url: indexUrl, + data: function (d) { + console.log("DataTables request data:", d); + return d; + }, + error: function (xhr, error, thrown) { + console.error("DataTables error:", xhr, error, thrown); + }, + }, + columns: [ + { data: "product_code", name: "product_code" }, + { data: "product_name", name: "product_name" }, + { data: "product_category", name: "product_category" }, + { data: "unit", name: "unit" }, + { + data: "quantity_required", + name: "quantity_required", + className: "text-right", + }, + { data: "notes", name: "notes" }, + { + data: "action", + name: "action", + orderable: false, + searchable: false, + className: "text-center", + width: "auto", + render: function (data, type, row) { + return data; + }, + }, + ], + pageLength: 25, + responsive: true, + autoWidth: false, + columnDefs: [ + { + targets: -1, // Action column + className: "text-center", + width: "auto", + orderable: false, + }, + ], + }); + + // Add Work Product + $("#addWorkProduct").click(function () { + $("#workProductForm")[0].reset(); + $("#work_product_id").val(""); + $("#modalHeading").text("Tambah Produk"); + $("#workProductModal").modal("show"); + + // Reset Select2 + $("#product_id").val("").trigger("change"); + }); + + // Modal close events + $("#workProductModal").on("hidden.bs.modal", function () { + $("#workProductForm")[0].reset(); + $("#work_product_id").val(""); + + // Reset Select2 + $("#product_id").val("").trigger("change"); + }); + + $("#stockPredictionModal").on("hidden.bs.modal", function () { + $("#stockPredictionContent").html(""); + $("#prediction_quantity").val(1); + }); + + // Manual modal close handlers for better compatibility + $(document).on("click", '[data-dismiss="modal"]', function () { + console.log("Modal dismiss clicked"); + $(this).closest(".modal").modal("hide"); + }); + + // Close button click handler + $(document).on("click", ".modal .close", function () { + console.log("Modal close button clicked"); + $(this).closest(".modal").modal("hide"); + }); + + // Modal backdrop click handler + $(document).on("click", ".modal", function (e) { + if (e.target === this) { + console.log("Modal backdrop clicked"); + $(this).modal("hide"); + } + }); + + // ESC key handler for modals + $(document).on("keydown", function (e) { + if (e.keyCode === 27) { + console.log("ESC key pressed"); + $(".modal.show").modal("hide"); + } + }); + + // Force close modal function + window.closeModal = function (modalId) { + console.log("Force closing modal:", modalId); + $("#" + modalId).modal("hide"); + }; + + // Additional modal close handlers + $(document).on( + "click", + '.btn-secondary[data-dismiss="modal"]', + function () { + console.log("Secondary button clicked"); + $(this).closest(".modal").modal("hide"); + } + ); + + // Bootstrap 4/5 compatible modal close + $(document).on("click", '.btn[data-dismiss="modal"]', function () { + console.log("Button with data-dismiss clicked"); + $(this).closest(".modal").modal("hide"); + }); + + // Alternative close button selector + $(document).on("click", ".modal-footer .btn-secondary", function () { + console.log("Footer secondary button clicked"); + $(this).closest(".modal").modal("hide"); + }); + + // Bootstrap 5 fallback + $(document).on("click", '[data-bs-dismiss="modal"]', function () { + console.log("Bootstrap 5 dismiss clicked"); + $(this).closest(".modal").modal("hide"); + }); + + // Generic secondary button in modal + $(document).on("click", ".modal .btn-secondary", function () { + console.log("Generic secondary button clicked"); + $(this).closest(".modal").modal("hide"); + }); + + // Show Stock Prediction + $("#showStockPrediction").click(function () { + console.log("Show stock prediction clicked"); + loadStockPrediction(); + $("#stockPredictionModal").modal("show"); + }); + + // Apply prediction button + $("#applyPrediction").click(function () { + console.log("Apply prediction clicked"); + loadStockPrediction(); + }); + + // Submit Form + $("#workProductForm").submit(function (e) { + e.preventDefault(); + + let formData = $(this).serialize(); + let workProductId = $("#work_product_id").val(); + let url, method; + + if (workProductId) { + // Update + const currentUpdateUrlTemplate = + updateUrlTemplate || + $('input[name="update_url_template"]').val(); + url = currentUpdateUrlTemplate.replace(":id", workProductId); + method = "PUT"; + } else { + // Create + url = storeUrl; + method = "POST"; + } + + $.ajax({ + url: url, + method: method, + data: formData, + success: function (response) { + $("#workProductModal").modal("hide"); + table.ajax.reload(); + + if (typeof Swal !== "undefined") { + Swal.fire({ + icon: "success", + title: "Berhasil!", + text: response.message, + timer: 2000, + showConfirmButton: false, + }); + } else { + alert("Berhasil: " + response.message); + } + }, + error: function (xhr) { + console.error("Error saving work product:", xhr); + let errors = xhr.responseJSON.errors || {}; + let message = xhr.responseJSON.message || "Terjadi kesalahan"; + + if (typeof Swal !== "undefined") { + Swal.fire({ + icon: "error", + title: "Error!", + text: message, + }); + } else { + alert("Error: " + message); + } + }, + }); + }); + + function loadStockPrediction() { + let quantity = $("#prediction_quantity").val(); + + // Validate quantity + if (!quantity || quantity < 1) { + $("#stockPredictionContent").html( + '
Masukkan jumlah pekerjaan yang valid (minimal 1).
' + ); + return; + } + + // Show loading state + $("#stockPredictionContent").html( + '
Memuat data...
' + ); + + // Add timeout for loading state + let loadingTimeout = setTimeout(function () { + $("#stockPredictionContent").html( + '
Memuat data... (mungkin memakan waktu beberapa saat)
' + ); + }, 2000); + + $.ajax({ + url: stockPredictionUrl, + method: "GET", + data: { quantity: quantity }, + timeout: 10000, // 10 second timeout + success: function (response) { + clearTimeout(loadingTimeout); + let html = ""; + + if (response.data.length === 0) { + html = + '
Belum ada produk yang dikonfigurasi untuk pekerjaan ini.
'; + } else { + html = + '
'; + html += + ""; + + response.data.forEach(function (item) { + html += ""; + html += + ""; + html += + '"; + html += + '"; + html += ""; + html += ""; + }); + + html += "
ProdukQty per PekerjaanTotal DibutuhkanCatatan
" + + item.product_code + + " - " + + item.product_name + + "' + + item.quantity_per_work + + " " + + item.unit + + "' + + item.total_quantity_needed + + " " + + item.unit + + "" + (item.notes || "-") + "
"; + } + + $("#stockPredictionContent").html(html); + }, + error: function (xhr, status, error) { + clearTimeout(loadingTimeout); + console.error( + "Error loading stock prediction:", + xhr, + status, + error + ); + let errorMessage = "Gagal memuat prediksi stock."; + if (xhr.responseJSON && xhr.responseJSON.message) { + errorMessage = xhr.responseJSON.message; + } else if (status === "timeout") { + errorMessage = "Request timeout. Silakan coba lagi."; + } else if (status === "error") { + errorMessage = + "Terjadi kesalahan jaringan. Silakan coba lagi."; + } + $("#stockPredictionContent").html( + '
' + errorMessage + "
" + ); + }, + }); + } +}); + +// Edit Work Product - Using delegated event for dynamic content +$(document).on("click", ".btn-edit-work-product", function () { + let id = $(this).data("id"); + editWorkProduct(id); +}); + +function editWorkProduct(id) { + // Get URL template from hidden input if not available globally + const currentShowUrlTemplate = + showUrlTemplate || $('input[name="show_url_template"]').val(); + let url = currentShowUrlTemplate.replace(":id", id); + console.log("Edit URL:", url); + console.log("Work ID:", workId, "Product ID:", id); + console.log("Hidden inputs:", { + work_id: $('input[name="work_id"]').val(), + show_url_template: $('input[name="show_url_template"]').val(), + }); + + $.ajax({ + url: url, + method: "GET", + success: function (response) { + console.log("Edit response:", response); + let data = response.data; + + $("#work_product_id").val(data.id); + $("#product_id").val(data.product_id).trigger("change"); + $("#quantity_required").val(data.quantity_required); + $("#notes").val(data.notes); + + $("#modalHeading").text("Edit Produk"); + $("#workProductModal").modal("show"); + }, + error: function (xhr) { + console.error("Error fetching work product:", xhr); + console.error("Response:", xhr.responseText); + console.error("Status:", xhr.status); + console.error("URL attempted:", url); + if (typeof Swal !== "undefined") { + Swal.fire({ + icon: "error", + title: "Error!", + text: "Gagal mengambil data produk", + }); + } else { + alert("Error: Gagal mengambil data produk"); + } + }, + }); +} + +// Delete Work Product - Using delegated event for dynamic content +$(document).on("click", ".btn-delete-work-product", function () { + let id = $(this).data("id"); + deleteWorkProduct(id); +}); + +function deleteWorkProduct(id) { + if (typeof Swal !== "undefined") { + Swal.fire({ + title: "Hapus Produk?", + text: "Data yang dihapus tidak dapat dikembalikan!", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#d33", + cancelButtonColor: "#3085d6", + confirmButtonText: "Ya, Hapus!", + cancelButtonText: "Batal", + }).then((result) => { + if (result.isConfirmed) { + performDelete(id); + } + }); + } else { + if ( + confirm("Hapus Produk? Data yang dihapus tidak dapat dikembalikan!") + ) { + performDelete(id); + } + } +} + +function performDelete(id) { + // Get URL template and csrfToken from hidden input if not available globally + const currentDestroyUrlTemplate = + destroyUrlTemplate || $('input[name="destroy_url_template"]').val(); + const currentCsrfToken = csrfToken || $('input[name="csrf_token"]').val(); + let url = currentDestroyUrlTemplate.replace(":id", id); + console.log("Delete URL:", url); + console.log("Work ID:", workId, "Product ID:", id); + + $.ajax({ + url: url, + method: "DELETE", + data: { + _token: currentCsrfToken, + }, + success: function (response) { + console.log("Delete response:", response); + $("#workProductsTable").DataTable().ajax.reload(); + + if (typeof Swal !== "undefined") { + Swal.fire({ + icon: "success", + title: "Berhasil!", + text: response.message, + timer: 2000, + showConfirmButton: false, + }); + } else { + alert("Berhasil: " + response.message); + } + }, + error: function (xhr) { + console.error("Error deleting work product:", xhr); + console.error("Response:", xhr.responseText); + console.error("Status:", xhr.status); + console.error("URL attempted:", url); + if (typeof Swal !== "undefined") { + Swal.fire({ + icon: "error", + title: "Error!", + text: "Gagal menghapus produk", + }); + } else { + alert("Error: Gagal menghapus produk"); + } + }, + }); +} diff --git a/public/js/pages/back/master/work.js b/public/js/pages/back/master/work.js index 7ad0b19..b48f269 100755 --- a/public/js/pages/back/master/work.js +++ b/public/js/pages/back/master/work.js @@ -1,104 +1,221 @@ +// Global variables +let ajaxUrl, storeUrl, table; + $.ajaxSetup({ headers: { - 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') - } + "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content"), + }, }); -var table = $('#kt_table').DataTable({ - processing: true, - serverSide: true, - ajax: $("input[name='ajax_url']"), - columns: [ - {data: 'category_name', name: 'c.name'}, - {data: 'name', name: 'w.name'}, - {data: 'shortname', name: 'w.shortname'}, - {data: 'desc', name: 'w.desc'}, - {data: 'action', name: 'action', orderable: false, searchable: false}, - ] +$(document).ready(function () { + // Get URLs from hidden inputs + ajaxUrl = $('input[name="ajax_url"]').val(); + storeUrl = $('input[name="store_url"]').val(); + + // Initialize DataTable + table = $("#kt_table").DataTable({ + processing: true, + serverSide: true, + ajax: { + url: ajaxUrl, + }, + columns: [ + { data: "category_name", name: "c.name" }, + { data: "name", name: "w.name" }, + { data: "shortname", name: "w.shortname" }, + { data: "desc", name: "w.desc" }, + { + data: "action", + name: "action", + orderable: false, + searchable: false, + className: "text-center", + width: "auto", + render: function (data, type, row) { + return data; + }, + }, + ], + responsive: true, + autoWidth: false, + columnDefs: [ + { + targets: -1, // Action column + className: "text-center", + width: "auto", + orderable: false, + }, + ], + }); + + // Initialize Select2 + $("#category_id").select2({ + placeholder: "-- Pilih Kategori --", + allowClear: true, + width: "100%", + dropdownParent: $("#workModal"), + language: { + noResults: function () { + return "Tidak ada hasil yang ditemukan"; + }, + searching: function () { + return "Mencari..."; + }, + }, + }); + + // Modal close handlers + $(document).on("click", '[data-dismiss="modal"]', function () { + $(this).closest(".modal").modal("hide"); + }); + + $(document).on("click", ".modal .close", function () { + $(this).closest(".modal").modal("hide"); + }); + + $(document).on("click", ".modal", function (e) { + if (e.target === this) { + $(this).modal("hide"); + } + }); + + $(document).on("keydown", function (e) { + if (e.keyCode === 27) { + $(".modal.show").modal("hide"); + } + }); + + // Bootstrap 5 fallback + $(document).on("click", '[data-bs-dismiss="modal"]', function () { + $(this).closest(".modal").modal("hide"); + }); + + // Alternative close button selectors + $(document).on("click", ".btn-secondary", function () { + if ($(this).closest(".modal").length) { + $(this).closest(".modal").modal("hide"); + } + }); + + // Force close function + window.closeModal = function (modalId) { + $("#" + modalId).modal("hide"); + }; + + // Modal hidden event + $("#workModal").on("hidden.bs.modal", function () { + $("#workForm").trigger("reset"); + $("#category_id").val("").trigger("change"); + $('#workForm input[name="_method"]').remove(); + }); + + // Add Work + $("#addWork").click(function () { + $("#workModal").modal("show"); + let form_action = storeUrl; + $("#workForm").attr("action", form_action); + $("#workForm input[name='_method']").remove(); + $("#workForm").attr("data-form", "store"); + $("#workForm").trigger("reset"); + $("#workForm textarea[name='desc']").val(""); + + // Reset Select2 + $("#category_id").val("").trigger("change"); + }); + + // Submit Form + $("#workForm").submit(function (e) { + e.preventDefault(); + let dataForm = $("#workForm").attr("data-form"); + if (dataForm == "store") { + $.ajax({ + url: $("#workForm").attr("action"), + type: "POST", + data: $("#workForm").serialize(), + success: function (res) { + $("#workModal").modal("hide"); + $("#workForm").trigger("reset"); + $("#category_id").val("").trigger("change"); + table.ajax.reload(); + Swal.fire({ + icon: "success", + title: "Berhasil!", + text: "Data pekerjaan berhasil disimpan.", + timer: 2000, + showConfirmButton: false, + }); + }, + }); + } else if (dataForm == "update") { + $.ajax({ + url: $("#workForm").attr("action"), + type: "POST", + data: $("#workForm").serialize(), + success: function (res) { + $("#workModal").modal("hide"); + $("#workForm").trigger("reset"); + $("#category_id").val("").trigger("change"); + table.ajax.reload(); + Swal.fire({ + icon: "success", + title: "Berhasil!", + text: "Data pekerjaan berhasil diupdate.", + timer: 2000, + showConfirmButton: false, + }); + }, + }); + } + }); }); - -$("#addWork").click(function() { - $("#workModal").modal("show") - let form_action = $("input[name='store_url']").val() - $("#workForm").attr('action', form_action) - $("#workForm input[name='_method']").remove() - $("#workForm").attr('data-form', 'store') - $("#workForm").trigger("reset") - $("#workForm textarea[name='desc']").val("") -}) - +// Global functions for edit and delete function destroyWork(id) { - let action = $("#destroyWork"+id).attr("data-action") + let action = $("#destroyWork" + id).attr("data-action"); Swal.fire({ - title: 'Hapus Pekerjaan?', + title: "Hapus Pekerjaan?", text: "Anda tidak akan bisa mengembalikannya!", showCancelButton: true, - confirmButtonColor: '#d33', - cancelButtonColor: '#dedede', - confirmButtonText: 'Hapus' + confirmButtonColor: "#d33", + cancelButtonColor: "#dedede", + confirmButtonText: "Hapus", }).then((result) => { if (result.value) { $.ajax({ url: action, - type: 'POST', + type: "POST", data: { - _token: $('meta[name="csrf-token"]').attr('content'), - _method: 'DELETE' + _token: $('meta[name="csrf-token"]').attr("content"), + _method: "DELETE", }, - success: function(res) { + success: function (res) { Swal.fire( - 'Dealer Dihapus!' - ) - table.ajax.reload() - } - }) + "Berhasil!", + "Pekerjaan berhasil dihapus.", + "success" + ); + if (table) { + table.ajax.reload(); + } + }, + }); } - }) + }); } function editWork(id) { - let form_action = $("#editWork"+id).attr("data-action") - let edit_url = $("#editWork"+id).attr("data-url") - $("#workModal").modal("show") - $("#workForm").append('') - $("#workForm").attr('action', form_action) - $("#workForm").attr('data-form', 'update') - $.get(edit_url, function(res) { - $("#workForm input[name='name']").val(res.data.name) - $("#workForm input[name='shortname']").val(res.data.shortname) - $("#workForm textarea[name='desc']").html(res.data.desc) - $("#workForm option[value='"+ res.data.category_id +"']").prop('selected', true); - }) + let form_action = $("#editWork" + id).attr("data-action"); + let edit_url = $("#editWork" + id).attr("data-url"); + $("#workModal").modal("show"); + $("#workForm").append(''); + $("#workForm").attr("action", form_action); + $("#workForm").attr("data-form", "update"); + $.get(edit_url, function (res) { + $("#workForm input[name='name']").val(res.data.name); + $("#workForm input[name='shortname']").val(res.data.shortname); + $("#workForm textarea[name='desc']").html(res.data.desc); + $("#workForm select[name='category_id']") + .val(res.data.category_id) + .trigger("change"); + }); } - -$(document).ready(function () { - $("#workForm").submit(function(e) { - e.preventDefault(); - let dataForm = $("#workForm").attr('data-form') - if(dataForm == 'store') { - $.ajax({ - url: $('#workForm').attr("action"), - type: 'POST', - data: $('#workForm').serialize(), - success: function(res) { - $("#workModal").modal("hide") - $('#workForm').trigger("reset") - table.ajax.reload() - } - }) - }else if(dataForm == 'update') { - $.ajax({ - url: $('#workForm').attr("action"), - type: 'POST', - data: $('#workForm').serialize(), - success: function(res) { - $("#workModal").modal("hide") - $('#workForm').trigger("reset") - table.ajax.reload() - } - }) - } - }) - -}); \ No newline at end of file diff --git a/resources/views/back/master/work-products.blade.php b/resources/views/back/master/work-products.blade.php new file mode 100644 index 0000000..196089f --- /dev/null +++ b/resources/views/back/master/work-products.blade.php @@ -0,0 +1,531 @@ +@extends('layouts.backapp') + +@section('content') + +
+
+
+ + + +

+ Kelola Produk untuk Pekerjaan: {{ $work->name }} +

+
+
+
+
+ + Kembali + + + @can('create', $menus['work.index'] ?? null) + + @endcan +
+
+
+
+ +
+ +
+
+
+ + Informasi Pekerjaan +
+
+
+
+
+
+ +
{{ $work->name }}
+
+
+ +
{{ $work->shortname }}
+
+
+
+
+ +
+ @if($work->category) + {{ $work->category->name }} + @else + - + @endif +
+
+
+ +
+ @if($work->desc) + {{ $work->desc }} + @else + Tidak ada deskripsi + @endif +
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + +
Kode ProdukNama ProdukKategoriSatuanQty DiperlukanCatatanAksi
+ +
+
+
+ + + + + + + + + + + + + + + + + + + +@endsection + +@section('javascripts') + +@endsection + +@section('styles') + +@endsection \ No newline at end of file diff --git a/resources/views/back/master/work.blade.php b/resources/views/back/master/work.blade.php index e0f80bf..3c0c39c 100755 --- a/resources/views/back/master/work.blade.php +++ b/resources/views/back/master/work.blade.php @@ -6,6 +6,243 @@ #nama{ text-transform: uppercase; } + + /* Action button flex layout */ + .d-flex.flex-row.gap-1 { + display: flex !important; + flex-direction: row !important; + gap: 0.25rem !important; + align-items: center; + flex-wrap: nowrap; + } + + .d-flex.flex-row.gap-1 .btn { + white-space: nowrap; + margin: 0; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + border-radius: 0.2rem; + } + + .d-flex.flex-row.gap-1 .btn i { + margin-right: 0.25rem; + } + + /* Ensure DataTables doesn't break flex layout */ + .dataTables_wrapper .dataTables_scrollBody .d-flex { + display: flex !important; + } + + /* Responsive adjustments */ + @media (max-width: 768px) { + .d-flex.flex-row.gap-1 { + flex-direction: column !important; + gap: 0.125rem !important; + } + + .d-flex.flex-row.gap-1 .btn { + width: 100%; + margin-bottom: 0.125rem; + } + } + + /* Modal and Form Styling */ + .modal-header { + border-bottom: 1px solid #dee2e6; + background-color: #f8f9fa; + } + + .modal-header h5 { + font-weight: 600; + color: #495057; + } + + .form-group label { + font-size: 0.9rem; + margin-bottom: 0.5rem; + } + + .form-group label i { + color: #6c757d; + } + + .form-control { + border-radius: 0.375rem; + border: 1px solid #ced4da; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + + .form-control:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + } + + .btn { + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.15s ease-in-out; + } + + .btn i { + font-size: 0.875rem; + } + + /* Required field indicator */ + .text-danger { + color: #dc3545 !important; + } + + /* Placeholder styling */ + .form-control::placeholder { + color: #6c757d; + opacity: 0.7; + } + + /* Select2 Styling */ + .select2-container--default .select2-selection--single { + border: 1px solid #ced4da; + border-radius: 0.375rem; + height: 38px; + line-height: 36px; + background-color: #fff; + } + + .select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: 36px; + padding-left: 12px; + padding-right: 30px; + color: #495057; + font-size: 14px; + } + + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #6c757d; + opacity: 0.7; + } + + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 36px; + width: 30px; + } + + .select2-container--default .select2-selection--single .select2-selection__clear { + color: #6c757d; + margin-right: 25px; + font-weight: bold; + } + + .select2-container--default .select2-selection--single .select2-selection__clear:hover { + color: #dc3545; + } + + .select2-container--default.select2-container--focus .select2-selection--single { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + } + + .select2-dropdown { + border: 1px solid #ced4da; + border-radius: 0.375rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + background-color: #fff; + } + + .select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #ced4da; + border-radius: 0.25rem; + padding: 8px 12px; + font-size: 14px; + } + + .select2-container--default .select2-search--dropdown .select2-search__field:focus { + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + outline: none; + } + + .select2-container--default .select2-results__option { + padding: 8px 12px; + font-size: 14px; + color: #495057; + } + + .select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #007bff; + color: #fff; + } + + .select2-container--default .select2-results__option[aria-selected=true] { + background-color: #e9ecef; + color: #495057; + } + + .select2-container--default .select2-results__option[aria-selected=true]:hover { + background-color: #007bff; + color: #fff; + } + + .select2-container { + width: 100% !important; + } + + /* Ensure Select2 works properly in modal */ + .modal .select2-container { + z-index: 9999; + } + + .modal .select2-dropdown { + z-index: 9999; + } + + /* Fix Select2 height to match form controls */ + .select2-container--default .select2-selection--single { + height: calc(1.5em + 0.75rem + 2px); + line-height: 1.5; + } + + .select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: 1.5; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + } + + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: calc(1.5em + 0.75rem); + top: 0; + } + + /* SweetAlert Icon Centering */ + .swal2-icon { + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin: 0 auto !important; + } + + .swal2-icon .swal2-icon-content { + display: flex !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + height: 100% !important; + } + + .swal2-popup { + text-align: center !important; + } + + .swal2-title { + text-align: center !important; + } + + .swal2-content { + text-align: center !important; + } + + .swal2-actions { + justify-content: center !important; + }
@@ -22,7 +259,7 @@
- +
@@ -52,41 +289,73 @@