diff --git a/app/Http/Controllers/Api/StockBatchController.php b/app/Http/Controllers/Api/StockBatchController.php new file mode 100644 index 0000000..9e35674 --- /dev/null +++ b/app/Http/Controllers/Api/StockBatchController.php @@ -0,0 +1,274 @@ +with(['user', 'supplier', 'stockEntries.statusHistory.status', 'files']); + + // Apply filters if provided + if ($request->has('search')) { + $search = $request->search; + $query->whereHas('physicalItem', function($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + } + + // Sort + $sortField = $request->input('sort_field', 'updated_at'); + $sortDirection = $request->input('sort_direction', 'desc'); + $query->orderBy($sortField, $sortDirection); + + // Paginate + $perPage = $request->input('per_page', 10); + $page = $request->input('page', 1); + + $entries = $query->paginate($perPage, ['*'], 'page', $page); + + return response()->json([ + 'data' => $entries->items(), + 'meta' => [ + 'total' => $entries->total(), + 'per_page' => $entries->perPage(), + 'current_page' => $entries->currentPage(), + 'last_page' => $entries->lastPage(), + ], + ]); + } + + /** + * Store a newly created stock batch, with multiple files. + */ + public function addData(Request $request) + { + try { + + + $validator = Validator::make($request->all(), [ + 'supplier_id' => 'nullable|integer', + 'tracking_number' => 'nullable|string', + 'arrival_date' => 'nullable|date', + 'files.*' => 'file', + 'file_types' => 'array', + 'file_types.*' => 'in:invoice,label,other', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + // create the batch + $batch = StockBatch::create([ + 'user_id' => auth()->id() ?? 1, + 'supplier_id' => $request->input('supplier_id'), + 'tracking_number' => $request->input('tracking_number'), + 'arrival_date' => $request->input('arrival_date'), + ]); + + // attach each uploaded file + if ($request->hasFile('files')) { + foreach ($request->file('files') as $i => $upload) { + $batch->files()->create([ + 'filename' => $upload->getClientOriginalName(), + 'file_data' => file_get_contents($upload->getRealPath()), + 'file_type' => $request->input("file_types.{$i}", 'other'), + 'user_id' => auth()->id() ?? 1, + ]); + } + } + + return response()->json([ + 'message' => 'Stock batch created successfully', + 'data' => $batch->load(['supplier', 'user', 'files', 'stockEntries']), + ], 201); + } + catch (\Exception $e) { + return response()->json(['errors' => $e->getMessage()], 500); + } + } + + /** + * Display the specified stock batch, with its files & entries. + */ + public function show($id) + { + $batch = StockBatch::with(['user','supplier','files','stockEntries']) + ->findOrFail($id); + + return response()->json(['data' => $batch]); + } + + /** + * Update the specified stock batch and optionally add new files. + */ + public function updateData(Request $request, $id) + { + $validator = Validator::make($request->all(), [ + 'supplier_id' => 'nullable|integer|exists:supplier,id', + 'tracking_number' => 'nullable|integer', + 'arrival_date' => 'nullable|date', + 'files.*' => 'file', + 'file_types' => 'array', + 'file_types.*' => 'in:invoice,label,other', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 422); + } + + $batch = StockBatch::findOrFail($id); + $batch->update($request->only(['supplier_id','tracking_number','arrival_date']) + [ + 'updated_at' => now(), + ]); + + // if there are new files, attach them + if ($request->hasFile('files')) { + foreach ($request->file('files') as $i => $upload) { + $batch->files()->create([ + 'filename' => $upload->getClientOriginalName(), + 'file_data' => file_get_contents($upload->getRealPath()), + 'file_type' => $request->input("file_types.{$i}", 'other'), + 'user_id' => auth()->id() ?? 1, + ]); + } + } + + return response()->json([ + 'message' => 'Stock batch updated successfully', + 'data' => $batch->fresh(['supplier','user','files','stockEntries']), + ]); + } + + /** + * Remove the specified stock batch (and its files). + */ + public function destroy($id) + { + $batch = StockBatch::with('files')->findOrFail($id); + + // delete related files first if you need to clean up storage + foreach ($batch->files as $file) { + $file->delete(); + } + + $batch->delete(); + + return response()->json([ + 'message' => 'Stock batch deleted successfully', + ]); + } + /** + * Get options for dropdown lists. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getOptions() + { + $stockPositions = StockSection::doesntHave('entries') + ->with('position.shelf.rack.line.room') + ->get() + ->map(function (StockSection $section) { + $pos = $section->position; + $shelf = $pos->shelf; + $rack = $shelf->rack; + $line = $rack->line; + $room = $line->room; + + return [ + 'id' => $section->section_id, + 'name' => sprintf( + '%s-%s-%s-%s-%s-%s', + $room->room_symbol, + $line->line_symbol, + $rack->rack_symbol, + $shelf->shelf_symbol, + $pos->position_symbol, + $section->section_symbol + ), + ]; + }); + + // Get suppliers from warehouse DB + $suppliers = Supplier::select('id', 'name')->get(); + + // Get physical items from warehouse DB + $countriesOrigin = OriginCountry::select('id', 'code as name')->get(); + + return response()->json([ + 'stockPositions' => $stockPositions, + 'suppliers' => $suppliers, + 'countriesOrigin' => $countriesOrigin, + ]); + } + + /** + * Get options for dropdown lists. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getItems(Request $request) + { + // Get physical items from warehouse DB + $physicalItems = PhysicalItem::select('id', 'name') + ->where('name', 'like', '%' . $request->input('item_name', '') . '%') + ->get(); + + return response()->json([ + 'physicalItems' => $physicalItems, + ]); + } + + /** + * Get options for dropdown lists. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getEntries(Request $request, $id) + { + // Get physical items from warehouse DB + $stockEntries = StockEntry::with(['physicalItem', 'supplier', 'sections', 'statusHistory'])->where('stock_batch_id', $id)->get(); + + return response()->json([ + "data" => $stockEntries + ]); + } + + + public function addEntries(Request $request, $id) + { + // Get physical items from warehouse DB + $stockEntries = StockEntry::whereIn('id', $request->get('ids'))->get(); + + foreach ($stockEntries as $entry) { + $entry->update([ + 'stock_batch_id' => $id, + 'on_the_way' => false, + ]); + } + + return response()->json([ + 'message' => 'Batch entries updated successfully', + 'data' => $entry->fresh(), + ]); + } +} diff --git a/app/Http/Controllers/Api/StockEntryController.php b/app/Http/Controllers/Api/StockEntryController.php index 0605ea7..4f1b4c8 100644 --- a/app/Http/Controllers/Api/StockEntryController.php +++ b/app/Http/Controllers/Api/StockEntryController.php @@ -5,11 +5,16 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\OriginCountry; use App\Models\StockEntry; +use App\Models\StockEntrySection; +use App\Models\StockEntryStatus; use App\Models\StockPosition; use App\Models\PhysicalItem; +use App\Models\StockSection; use App\Models\Supplier; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; +use OwenIt\Auditing\Models\Audit; class StockEntryController extends Controller { @@ -22,7 +27,7 @@ class StockEntryController extends Controller public function index(Request $request) { $query = StockEntry::query() - ->with(['physicalItem', 'supplier', 'stockPosition', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']); + ->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']); // Apply filters if provided if ($request->has('search')) { @@ -54,39 +59,104 @@ class StockEntryController extends Controller ]); } - /** - * Store a newly created stock entry. - * - * @param Request $request - * @return \Illuminate\Http\JsonResponse - */ + public function fetchOnTheWay(Request $request) + { + $query = StockEntry::query() + ->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin'])->where('on_the_way', 1); + + + // Paginate + $perPage = $request->input('per_page', 10); + $page = $request->input('page', 1); + + $entries = $query->paginate($perPage, ['*'], 'page', $page); + + return response()->json([ + 'data' => $entries->items(), + 'meta' => [ + 'total' => $entries->total(), + 'per_page' => $entries->perPage(), + 'current_page' => $entries->currentPage(), + 'last_page' => $entries->lastPage(), + ], + ]); + } + public function addData(Request $request) { - $validator = Validator::make($request->all(), [ - 'physical_item_id' => 'required|integer|exists:vat_warehouse.physical_item,id', - 'supplier_id' => 'required|integer|exists:vat_warehouse.supplier,id', - 'count' => 'required|integer|min:0', - 'price' => 'nullable|numeric|min:0', - 'bought' => 'nullable|date', - 'description' => 'nullable|string', - 'note' => 'nullable|string', - 'stock_position_id' => 'required|integer|exists:stock_positions,id', + // build base rules + $rules = [ + 'physical_item_id' => 'required|integer|exists:vat_warehouse.physical_item,id', + 'supplier_id' => 'required|integer|exists:vat_warehouse.supplier,id', + 'count' => 'required|integer|min:0', + 'price' => 'nullable|numeric|min:0', + 'bought' => 'nullable|date', + 'description' => 'nullable|string', + 'note' => 'nullable|string', + 'stock_batch_id' => 'nullable|integer|exists:stock_batch,id', 'country_of_origin_id' => 'required|integer|exists:vat_warehouse.country_of_origin,id', - 'on_the_way' => 'boolean', - 'stock_batch_id' => 'nullable|integer|exists:stock_batch,id', - ]); + 'on_the_way' => 'boolean', + ]; + + // condition for requiring section + count + $needsSection = function() use ($request) { + return ! $request->boolean('on_the_way') + && !is_null($request->input('stock_batch_id')); + }; + + // add conditional rules + $rules['stock_position_id'] = [ + Rule::requiredIf($needsSection), + 'integer', + 'exists:stock_section,section_id' + ]; + $rules['section_count'] = [ + Rule::requiredIf($needsSection), + 'integer', + 'min:1' + ]; + + $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { return response()->json(['errors' => $validator->errors()], 422); } - $entry = StockEntry::create($request->all() + [ - 'created_by' => 1, + // 1) create the main stock entry + $entry = StockEntry::create($request->only([ + 'physical_item_id', + 'supplier_id', + 'count', + 'price', + 'bought', + 'description', + 'note', + 'country_of_origin_id', + 'on_the_way', + 'stock_batch_id', + ]) + [ + 'created_by' => auth()->id() ?? 1, + ]); + + // 2) only attach to section pivot if needed + if ($needsSection()) { + $entry->sections()->attach( + $request->input('stock_position_id'), + ['count' => $request->input('section_count')] + ); + } + + // 3) eager-load relations (including the full address hierarchy) + $entry->load([ + 'physicalItem', + 'supplier', + 'stockBatch', + 'sections.position.shelf.rack.line.room', ]); return response()->json([ 'message' => 'Stock entry created successfully', - 'data' => $entry->load(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']), + 'data' => $entry, ], 201); } @@ -113,35 +183,120 @@ class StockEntryController extends Controller */ public function updateData(Request $request, $id) { - $validator = Validator::make($request->all(), [ - 'physical_item_id' => 'integer|exists:vat_warehouse.physical_item,id', - 'supplier_id' => 'integer|exists:vat_warehouse.supplier,id', - 'count' => 'integer|min:0', - 'price' => 'nullable|numeric|min:0', - 'bought' => 'nullable|date', - 'description' => 'nullable|string', - 'note' => 'nullable|string', - 'stock_position_id' => 'integer|exists:stock_positions,id', - 'country_of_origin_id' => 'integer|exists:vat_warehouse.country_of_origin,id', - 'on_the_way' => 'boolean', - 'stock_batch_id' => 'nullable|integer|exists:stock_batch,id', - ]); + // 1) pull apart + $entryForm = $request->input('entryForm', []); + $sections = $request->input('sections', []); + // 2) validation rules as before… + $rules = [ + 'entryForm.physical_item_id' => ['sometimes','integer','exists:vat_warehouse.physical_item,id'], + 'entryForm.supplier_id' => ['sometimes','integer','exists:vat_warehouse.supplier,id'], + 'entryForm.count' => ['sometimes','integer','min:0'], + 'entryForm.price' => ['nullable','numeric','min:0'], + 'entryForm.bought' => ['nullable','date'], + 'entryForm.description' => ['nullable','string'], + 'entryForm.note' => ['nullable','string'], + 'entryForm.stock_batch_id' => ['nullable','integer','exists:stock_batch,id'], + 'entryForm.country_of_origin_id' => ['sometimes','integer','exists:vat_warehouse.country_of_origin,id'], + 'entryForm.on_the_way' => ['sometimes','boolean'], + ]; + + // determine if sections are required + $needsSection = function() use ($request) { + return ! $request->input('entryForm.on_the_way', false) + && ! is_null($request->input('entryForm.stock_batch_id')); + }; + + // validate sections array if needed + $rules['sections'] = [Rule::requiredIf($needsSection), 'array']; + $rules['sections.*.stock_position_id'] = [Rule::requiredIf($needsSection), 'integer', 'exists:stock_section,section_id']; + $rules['sections.*.count'] = [Rule::requiredIf($needsSection), 'integer', 'min:1']; + + + $validator = Validator::make($request->all(), $rules); if ($validator->fails()) { - return response()->json(['errors' => $validator->errors()], 422); + return response()->json(['errors'=>$validator->errors()], 422); } - $entry = StockEntry::findOrFail($id); - $entry->update($request->all() + [ - 'updated_by' => 1, + /** @var StockEntry $entry */ + $entry = StockEntry::with('sections')->findOrFail($id); + + // 3) Update the main row (this is auto‐audited by OwenIt) + $entry->update(array_merge( + array_filter($entryForm, fn($v) => !is_null($v)), + ['updated_by'=> auth()->id() ?? 1] + )); + + // 4) Manual pivot‐audit: + // a) snapshot what was there + $oldPivots = $entry->sections + ->pluck('pivot.count','pivot.section_id') + ->toArray(); // [ section_id => oldCount, … ] + + // b) perform the sync + $syncData = []; + foreach ($sections as $sec) { + if (!empty($sec['stock_position_id']) && !empty($sec['count'])) { + $syncData[(int)$sec['stock_position_id']] = ['count'=>(int)$sec['count']]; + } + } +// $entry->sections()->sync($syncData); + // b) perform the sync, but don’t let the AuditableObserver fire here… + StockEntrySection::withoutAuditing(function() use ($entry, $syncData) { + $entry->sections()->sync($syncData); + }); + + // c) now compare old↔new and write audit rows + $userId = auth()->id() ?? null; + foreach ($syncData as $sectionId => $pivotData) { + $newCount = $pivotData['count']; + if (! array_key_exists($sectionId, $oldPivots)) { + // *** attached *** + Audit::create([ + 'user_id' => $userId, + 'auditable_type'=> StockEntrySection::class, + 'auditable_id' => $entry->id, // pivot has no single PK; we use entry ID + 'event' => 'created', + 'old_values' => [], + 'new_values' => ['section_id'=>$sectionId,'count'=>$newCount], + ]); + } elseif ($oldPivots[$sectionId] !== $newCount) { + // *** updated *** + Audit::create([ + 'user_id' => $userId, + 'auditable_type'=> StockEntrySection::class, + 'auditable_id' => $entry->id, + 'event' => 'updated', + 'old_values' => ['count'=>$oldPivots[$sectionId]], + 'new_values' => ['count'=>$newCount], + ]); + } + } + // d) any removed? + foreach (array_diff_key($oldPivots, $syncData) as $sectionId => $oldCount) { + Audit::create([ + 'user_id' => $userId, + 'auditable_type'=> StockEntrySection::class, + 'auditable_id' => $entry->id, + 'event' => 'deleted', + 'old_values' => ['section_id'=>$sectionId,'count'=>$oldCount], + 'new_values' => [], + ]); + } + + // 5) reload and return + $entry->load([ + 'physicalItem', + 'supplier', + 'stockBatch', + 'sections.position.shelf.rack.line.room', ]); return response()->json([ - 'message' => 'Stock entry updated successfully', - 'data' => $entry->fresh(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']), + 'message'=>'Stock entry updated successfully', + 'data' =>$entry, ]); } - /** * Remove the specified stock entry. * @@ -165,11 +320,29 @@ class StockEntryController extends Controller */ public function getOptions() { - $stockPositions = StockPosition::select('id', 'line', 'rack', 'shelf', 'position')->get() - ->map(function($position) { + + $stockPositions = StockSection::with('position.shelf.rack.line.room') + ->get() + ->map(function (StockSection $section) { + $pos = $section->position; + $shelf = $pos->shelf; + $rack = $shelf->rack; + $line = $rack->line; + $room = $line->room; + return [ - 'id' => $position->id, - 'name' => $position->line . '-' . $position->rack . '-' . $position->shelf . '-' . $position->position + 'id' => $section->section_id, + 'name' => sprintf( + '%s-%s-%s-%s-%s-%s', + $room->room_symbol, + $line->line_symbol, + $rack->rack_symbol, + $shelf->shelf_symbol, + $pos->position_symbol, + $section->section_symbol + ), + 'capacity' => $section->capacity, + 'occupied' => $section->occupied() ]; }); @@ -195,11 +368,41 @@ class StockEntryController extends Controller { // Get physical items from warehouse DB $physicalItems = PhysicalItem::select('id', 'name') - ->where('name', 'like', '%' . $request->input('item_name', '') . '%') + ->where('name', 'like', '%' . $request->input('item_name', '') . '%')->limit(100) ->get(); return response()->json([ 'physicalItems' => $physicalItems, ]); } + + public function audit(Request $request, $id) + { + // 1) Load the entry (so we can get its audits) + $entry = StockEntry::findOrFail($id); + + // 2) Get audits for the entry model itself + $entryAudits = $entry->audits()->orderBy('created_at')->get(); + + // 3) Get audits for all pivot changes you logged under the pivot model + $pivotAudits = Audit::query() + ->where('auditable_type', StockEntrySection::class) + ->where('auditable_id', $id) // we used entry->id when creating these + ->orderBy('created_at') + ->get(); + + return response()->json([ + 'entry_audits' => $entryAudits, + 'pivot_audits' => $pivotAudits, + ]); + } + + public function getStatusList(Request $request) + { + $entry = StockEntryStatus::all(); + + return response()->json([ + 'statuses' => $entry, + ]); + } } diff --git a/app/Http/Controllers/Api/StockEntryStatusController.php b/app/Http/Controllers/Api/StockEntryStatusController.php new file mode 100644 index 0000000..8cbda41 --- /dev/null +++ b/app/Http/Controllers/Api/StockEntryStatusController.php @@ -0,0 +1,43 @@ +validate([ + 'status' => [ + 'required', + 'integer', + // ensure the status ID exists in your statuses table + Rule::exists('stock_entries_status', 'id'), + ], + 'count' => ['nullable', 'integer', 'min:0'], + ]); + + // Create the history record + $history = StockEntryStatusHistory::create([ + 'stock_entries_id' => $stockEntry->id, + 'stock_entries_status_id' => $data['status'], + 'status_note' => isset($data['count']) + ? "Count: {$data['count']}" + : null, + 'section_id' => null + ]); + + return response()->json([ + 'message' => 'Status updated successfully.', + 'history' => $history, + ], 201); + } +} diff --git a/app/Http/Controllers/Api/StockHandleExpediceController.php b/app/Http/Controllers/Api/StockHandleExpediceController.php new file mode 100644 index 0000000..d7a8da4 --- /dev/null +++ b/app/Http/Controllers/Api/StockHandleExpediceController.php @@ -0,0 +1,65 @@ +validate([ + 'count' => ['required', 'integer', 'min:0'], + 'section_id' => ['required', 'integer'], +// 'mapping_id' => ['required', 'integer'], + ]); + + $entry = StockEntry::with('sections') + ->whereHas('sections', function($q) use ($data) { + $q->where('section_id', $data['section_id']); + }) + ->first(); + + // 1) Update the pivot table count for this section + $entry->sections()->updateExistingPivot($data['section_id'], [ + 'count' => $data['count'], + ]); + + // 2) Recalculate the total across all sections and save it on the entry + // (assuming you want `stock_entries.count` to always equal the sum of its section counts) + $total = $entry->sections() + ->get() // pull fresh pivot data + ->sum(fn($sec) => $sec->pivot->count); + + $entry->count = $total; + $entry->save(); + + return response()->json([ + 'message' => 'Section count and entry total updated', + 'data' => $entry->load('sections'), + ]); + + } + + public function getItems(Request $request) + { + // Get physical items from warehouse DB + $physicalItems = PhysicalItem::select('id', 'name') + ->where('name', 'like', '%' . $request->input('item_name', '') . '%')->limit(100) + ->get(); + + return response()->json([ + 'physicalItems' => $physicalItems, + ]); + } +} diff --git a/app/Http/Controllers/Api/SupplierController.php b/app/Http/Controllers/Api/SupplierController.php new file mode 100644 index 0000000..36bfbe4 --- /dev/null +++ b/app/Http/Controllers/Api/SupplierController.php @@ -0,0 +1,23 @@ +get(); + + // Get physical items from warehouse DB + $countriesOrigin = OriginCountry::select('id', 'code as name')->get(); + + return response()->json([ + 'suppliers' => $suppliers, + 'countriesOrigin' => $countriesOrigin, + ]); + } + +} diff --git a/app/Models/StockBatch.php b/app/Models/StockBatch.php index 953b646..de71978 100644 --- a/app/Models/StockBatch.php +++ b/app/Models/StockBatch.php @@ -2,35 +2,44 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class StockBatch extends Model { - use HasFactory; + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'stock_batch'; /** * The attributes that are mass assignable. * - * @var array + * @var array */ protected $fillable = [ - 'supplier_id', 'user_id', + 'supplier_id', + 'tracking_number', + 'arrival_date', ]; /** - * Get the supplier associated with the stock batch. + * The attributes that should be cast to native types. + * + * @var array */ - public function supplier(): BelongsTo - { - return $this->belongsTo(Supplier::class, 'supplier_id')->on('vat_warehouse'); - } + protected $casts = [ + 'arrival_date' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; /** - * Get the user associated with the stock batch. + * Get the user that owns the stock batch. */ public function user(): BelongsTo { @@ -38,10 +47,33 @@ class StockBatch extends Model } /** - * Get the stock entries for this batch. + * Get the supplier for the stock batch. */ + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + public function stockEntries(): HasMany { return $this->hasMany(StockEntry::class); } + + public function files() + { + return $this->hasMany(StockBatchFile::class)->select([ + 'id', + 'filename', + 'file_type', + 'stock_batch_id', + 'user_id', + 'created_at', + 'updated_at', + ]); + } + + public function statusHistory(): HasMany + { + return $this->hasMany(StockBatchStatusHistory::class, 'stock_batch_id'); + } } diff --git a/app/Models/StockBatchFile.php b/app/Models/StockBatchFile.php new file mode 100644 index 0000000..922b2a0 --- /dev/null +++ b/app/Models/StockBatchFile.php @@ -0,0 +1,34 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public function stockBatch(): BelongsTo + { + return $this->belongsTo(StockBatch::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/StockBatchStatus.php b/app/Models/StockBatchStatus.php new file mode 100644 index 0000000..ecf77dc --- /dev/null +++ b/app/Models/StockBatchStatus.php @@ -0,0 +1,26 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public function history(): HasMany + { + return $this->hasMany(StockBatchStatusHistory::class, 'stock_batch_status_id'); + } +} diff --git a/app/Models/StockBatchStatusHistory.php b/app/Models/StockBatchStatusHistory.php new file mode 100644 index 0000000..6718842 --- /dev/null +++ b/app/Models/StockBatchStatusHistory.php @@ -0,0 +1,32 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public function stockBatch(): BelongsTo + { + return $this->belongsTo(StockBatch::class); + } + + public function status(): BelongsTo + { + return $this->belongsTo(StockBatchStatus::class, 'stock_batch_status_id'); + } +} diff --git a/app/Models/StockEntries2Section.php b/app/Models/StockEntries2Section.php new file mode 100644 index 0000000..fc6de09 --- /dev/null +++ b/app/Models/StockEntries2Section.php @@ -0,0 +1,32 @@ +belongsTo(StockSection::class, 'section_id', 'section_id'); + } + + // assuming you have an App\Models\StockEntry model + public function entry() + { + return $this->belongsTo(StockEntry::class, 'entry_id', 'id'); + } +} diff --git a/app/Models/StockEntry.php b/app/Models/StockEntry.php index 46b40af..b9a21df 100644 --- a/app/Models/StockEntry.php +++ b/app/Models/StockEntry.php @@ -6,12 +6,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; +use OwenIt\Auditing\Auditable; -class StockEntry extends Model + +class StockEntry extends Model implements AuditableContract { - use HasFactory; + use HasFactory, Auditable; - protected $table = 'stock_entry'; + protected $table = 'stock_entries'; /** * The attributes that are mass assignable. @@ -22,11 +26,11 @@ class StockEntry extends Model 'physical_item_id', 'supplier_id', 'count', + 'original_count', 'price', 'bought', 'description', 'note', - 'stock_position_id', 'country_of_origin_id', 'on_the_way', 'stock_batch_id', @@ -60,14 +64,6 @@ class StockEntry extends Model return $this->belongsTo(Supplier::class, 'supplier_id'); } - /** - * Get the stock position associated with the stock entry. - */ - public function stockPosition(): BelongsTo - { - return $this->belongsTo(StockPosition::class); - } - /** * Get the stock batch associated with the stock entry. */ @@ -76,6 +72,11 @@ class StockEntry extends Model return $this->belongsTo(StockBatch::class); } + public function statusHistory(): HasMany + { + return $this->hasMany(StockEntryStatusHistory::class, 'stock_entries_id'); + } + /** * Get the attributes for this stock entry. */ @@ -93,4 +94,51 @@ class StockEntry extends Model { return $this->belongsTo(OriginCountry::class, 'country_of_origin_id'); } + + public function sections(): BelongsToMany + { + return $this->belongsToMany( + StockSection::class, + 'stock_entries2section', + 'entry_id', + 'section_id' + ) + ->using(StockEntrySection::class) + ->withPivot('count') + ->withTimestamps(); + } + + + + /** + * Return each storage “address” string: + * Room-Line-Rack-Shelf-Position-Section + * + * @return string[] + */ + public function storageAddresses(): array + { + // eager-load the whole hierarchy if not already + $this->loadMissing('sections.position.shelf.rack.line.room'); + + return $this->sections + ->map(function (StockSection $section) { + $pos = $section->position; + $shelf = $pos->shelf; + $rack = $shelf->rack; + $line = $rack->line; + $room = $line->room; + + return sprintf( + '%s-%s-%s-%s-%s-%s', + $room->room_symbol, + $line->line_symbol, + $rack->rack_symbol, + $shelf->shelf_symbol, + $pos->position_symbol, + $section->section_symbol + ); + }) + ->toArray(); + } } diff --git a/app/Models/StockEntrySection.php b/app/Models/StockEntrySection.php new file mode 100644 index 0000000..1d0caa6 --- /dev/null +++ b/app/Models/StockEntrySection.php @@ -0,0 +1,15 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public const NEW_GOODS_STOCKED = 1; + public const NEW_GOODS_COUNTED = 2; + public const NEW_GOODS_ARRIVED = 3; + public const BATCH_CREATED = 4; + public const MOVED_TO_NEW_POSITION = 5; + public const NEW_GOODS_DAMAGED = 6; + public const NEW_GOODS_MISSING = 7; + public const NEW_GOODS_SURPLUS = 8; + public const STOCK_MISSING = 9; + + public const STOCK_DISCARDED = 10; + public const STOCK_RETURNED = 11; + + + #naskladneno, spocitano, presunuto na XY, poskozeno, chybi, nadbyva + + public function history(): HasMany + { + return $this->hasMany(StockEntryStatusHistory::class, 'stock_entries_status_id'); + } +} diff --git a/app/Models/StockEntryStatusHistory.php b/app/Models/StockEntryStatusHistory.php new file mode 100644 index 0000000..ff8cd1a --- /dev/null +++ b/app/Models/StockEntryStatusHistory.php @@ -0,0 +1,38 @@ + 'datetime', + 'updated_at' => 'datetime', + ]; + + public function entry(): BelongsTo + { + return $this->belongsTo(StockEntry::class, 'stock_entries_id'); + } + + public function status(): BelongsTo + { + return $this->belongsTo(StockEntryStatus::class, 'stock_entries_status_id'); + } + + public function section(): BelongsTo + { + return $this->belongsTo(StockSection::class, 'section_id'); + } +} diff --git a/app/Models/StockLine.php b/app/Models/StockLine.php new file mode 100644 index 0000000..56d5709 --- /dev/null +++ b/app/Models/StockLine.php @@ -0,0 +1,27 @@ +belongsTo(StockRoom::class, 'room_id', 'room_id'); + } + + public function racks() + { + return $this->hasMany(StockRack::class, 'line_id', 'line_id'); + } +} diff --git a/app/Models/StockPosition.php b/app/Models/StockPosition.php index 2ee272e..6bf0dca 100644 --- a/app/Models/StockPosition.php +++ b/app/Models/StockPosition.php @@ -1,47 +1,27 @@ - */ protected $fillable = [ - 'line', - 'rack', - 'shelf', - 'position', + 'position_symbol', + 'position_name', + 'shelf_id', ]; - /** - * Get the full position string. - * - * @return string - */ - public function getFullPositionAttribute(): string + public function shelf() { - return "{$this->line}-{$this->rack}-{$this->shelf}-{$this->position}"; + return $this->belongsTo(StockShelf::class, 'shelf_id', 'shelf_id'); + } + + public function sections() + { + return $this->hasMany(StockSection::class, 'position_id', 'position_id'); } } diff --git a/app/Models/StockRack.php b/app/Models/StockRack.php new file mode 100644 index 0000000..0245097 --- /dev/null +++ b/app/Models/StockRack.php @@ -0,0 +1,27 @@ +belongsTo(StockLine::class, 'line_id', 'line_id'); + } + + public function shelves() + { + return $this->hasMany(StockShelf::class, 'rack_id', 'rack_id'); + } +} diff --git a/app/Models/StockRoom.php b/app/Models/StockRoom.php new file mode 100644 index 0000000..2af653a --- /dev/null +++ b/app/Models/StockRoom.php @@ -0,0 +1,21 @@ +hasMany(StockLine::class, 'room_id', 'room_id'); + } +} diff --git a/app/Models/StockSection.php b/app/Models/StockSection.php new file mode 100644 index 0000000..52164be --- /dev/null +++ b/app/Models/StockSection.php @@ -0,0 +1,43 @@ +belongsTo(StockPosition::class, 'position_id', 'position_id'); + } + + // If you have a StockEntry model, you can set up a many-to-many via the pivot: + public function entries() + { + return $this->belongsToMany( + StockEntry::class, // your entry model + 'stock_entries2section', // pivot table + 'section_id', // this modelʼs FK on pivot + 'entry_id' // other modelʼs FK on pivot + ) + ->withPivot('count') + ->withTimestamps(); + } + + public function occupied(): bool + { + return $this->entries()->exists(); + } + +} diff --git a/app/Models/StockShelf.php b/app/Models/StockShelf.php new file mode 100644 index 0000000..d0ed50b --- /dev/null +++ b/app/Models/StockShelf.php @@ -0,0 +1,27 @@ +belongsTo(StockRack::class, 'rack_id', 'rack_id'); + } + + public function positions() + { + return $this->hasMany(StockPosition::class, 'shelf_id', 'shelf_id'); + } +} diff --git a/composer.json b/composer.json index 9fabb8b..7828a38 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/jetstream": "^5.3", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", + "owen-it/laravel-auditing": "^14.0", "tightenco/ziggy": "^2.0" }, "require-dev": { @@ -75,4 +76,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index ca822b5..ef4cd31 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b26c4fac677e9b104c9fd2bcb97d5a4", + "content-hash": "87a6c86e9f85c22d4b6e67d73bb8823d", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2941,6 +2941,90 @@ ], "time": "2025-05-08T08:14:37+00:00" }, + { + "name": "owen-it/laravel-auditing", + "version": "v14.0.0", + "source": { + "type": "git", + "url": "https://github.com/owen-it/laravel-auditing.git", + "reference": "f92602d1b3f53df29ddd577290e9d735ea707c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/f92602d1b3f53df29ddd577290e9d735ea707c53", + "reference": "f92602d1b3f53df29ddd577290e9d735ea707c53", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/filesystem": "^11.0|^12.0", + "php": "^8.2" + }, + "require-dev": { + "mockery/mockery": "^1.5.1", + "orchestra/testbench": "^9.0|^10.0", + "phpunit/phpunit": "^11.0" + }, + "type": "package", + "extra": { + "laravel": { + "providers": [ + "OwenIt\\Auditing\\AuditingServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "v14-dev" + } + }, + "autoload": { + "psr-4": { + "OwenIt\\Auditing\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antério Vieira", + "email": "anteriovieira@gmail.com" + }, + { + "name": "Raphael França", + "email": "raphaelfrancabsb@gmail.com" + }, + { + "name": "Morten D. Hansen", + "email": "morten@visia.dk" + } + ], + "description": "Audit changes of your Eloquent models in Laravel", + "homepage": "https://laravel-auditing.com", + "keywords": [ + "Accountability", + "Audit", + "auditing", + "changes", + "eloquent", + "history", + "laravel", + "log", + "logging", + "lumen", + "observer", + "record", + "revision", + "tracking" + ], + "support": { + "issues": "https://github.com/owen-it/laravel-auditing/issues", + "source": "https://github.com/owen-it/laravel-auditing" + }, + "time": "2025-02-26T16:40:54+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.0.0", diff --git a/config/audit.php b/config/audit.php new file mode 100644 index 0000000..9c94d54 --- /dev/null +++ b/config/audit.php @@ -0,0 +1,198 @@ + env('AUDITING_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Audit Implementation + |-------------------------------------------------------------------------- + | + | Define which Audit model implementation should be used. + | + */ + + 'implementation' => OwenIt\Auditing\Models\Audit::class, + + /* + |-------------------------------------------------------------------------- + | User Morph prefix & Guards + |-------------------------------------------------------------------------- + | + | Define the morph prefix and authentication guards for the User resolver. + | + */ + + 'user' => [ + 'morph_prefix' => 'user', + 'guards' => [ + 'web', + 'api', + ], + 'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class, + ], + + /* + |-------------------------------------------------------------------------- + | Audit Resolvers + |-------------------------------------------------------------------------- + | + | Define the IP Address, User Agent and URL resolver implementations. + | + */ + 'resolvers' => [ + 'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class, + 'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class, + 'url' => OwenIt\Auditing\Resolvers\UrlResolver::class, + ], + + /* + |-------------------------------------------------------------------------- + | Audit Events + |-------------------------------------------------------------------------- + | + | The Eloquent events that trigger an Audit. + | + */ + + 'events' => [ + 'created', + 'updated', + 'deleted', + 'restored', + ], + + /* + |-------------------------------------------------------------------------- + | Strict Mode + |-------------------------------------------------------------------------- + | + | Enable the strict mode when auditing? + | + */ + + 'strict' => false, + + /* + |-------------------------------------------------------------------------- + | Global exclude + |-------------------------------------------------------------------------- + | + | Have something you always want to exclude by default? - add it here. + | Note that this is overwritten (not merged) with local exclude + | + */ + + 'exclude' => [], + + /* + |-------------------------------------------------------------------------- + | Empty Values + |-------------------------------------------------------------------------- + | + | Should Audit records be stored when the recorded old_values & new_values + | are both empty? + | + | Some events may be empty on purpose. Use allowed_empty_values to exclude + | those from the empty values check. For example when auditing + | model retrieved events which will never have new and old values. + | + | + */ + + 'empty_values' => true, + 'allowed_empty_values' => [ + 'retrieved', + ], + + /* + |-------------------------------------------------------------------------- + | Allowed Array Values + |-------------------------------------------------------------------------- + | + | Should the array values be audited? + | + | By default, array values are not allowed. This is to prevent performance + | issues when storing large amounts of data. You can override this by + | setting allow_array_values to true. + */ + 'allowed_array_values' => false, + + /* + |-------------------------------------------------------------------------- + | Audit Timestamps + |-------------------------------------------------------------------------- + | + | Should the created_at, updated_at and deleted_at timestamps be audited? + | + */ + + 'timestamps' => false, + + /* + |-------------------------------------------------------------------------- + | Audit Threshold + |-------------------------------------------------------------------------- + | + | Specify a threshold for the amount of Audit records a model can have. + | Zero means no limit. + | + */ + + 'threshold' => 0, + + /* + |-------------------------------------------------------------------------- + | Audit Driver + |-------------------------------------------------------------------------- + | + | The default audit driver used to keep track of changes. + | + */ + + 'driver' => 'database', + + /* + |-------------------------------------------------------------------------- + | Audit Driver Configurations + |-------------------------------------------------------------------------- + | + | Available audit drivers and respective configurations. + | + */ + + 'drivers' => [ + 'database' => [ + 'table' => 'audits', + 'connection' => null, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Audit Queue Configurations + |-------------------------------------------------------------------------- + | + | Available audit queue configurations. + | + */ + + 'queue' => [ + 'enable' => false, + 'connection' => 'sync', + 'queue' => 'default', + 'delay' => 0, + ], + + /* + |-------------------------------------------------------------------------- + | Audit Console + |-------------------------------------------------------------------------- + | + | Whether console events should be audited (eg. php artisan db:seed). + | + */ + + 'console' => false, +]; diff --git a/database/migrations/2025_05_28_070739_create_audits_table.php b/database/migrations/2025_05_28_070739_create_audits_table.php new file mode 100644 index 0000000..7ce54a6 --- /dev/null +++ b/database/migrations/2025_05_28_070739_create_audits_table.php @@ -0,0 +1,48 @@ +create($table, function (Blueprint $table) { + + $morphPrefix = config('audit.user.morph_prefix', 'user'); + + $table->bigIncrements('id'); + $table->string($morphPrefix . '_type')->nullable(); + $table->unsignedBigInteger($morphPrefix . '_id')->nullable(); + $table->string('event'); + $table->morphs('auditable'); + $table->text('old_values')->nullable(); + $table->text('new_values')->nullable(); + $table->text('url')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent', 1023)->nullable(); + $table->string('tags')->nullable(); + $table->timestamps(); + + $table->index([$morphPrefix . '_id', $morphPrefix . '_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $connection = config('audit.drivers.database.connection', config('database.default')); + $table = config('audit.drivers.database.table', 'audits'); + + Schema::connection($connection)->drop($table); + } +}; diff --git a/database/seeders/StockLocationSeeder.php b/database/seeders/StockLocationSeeder.php new file mode 100644 index 0000000..763b3ab --- /dev/null +++ b/database/seeders/StockLocationSeeder.php @@ -0,0 +1,64 @@ + 'Sklad', 'room_symbol' => 'W'], + ['room_name' => 'Sklep - prodejna', 'room_symbol' => 'SKLPR'], + ['room_name' => 'Sklep - main', 'room_symbol' => 'SKLM'], + ]; + + foreach ($rooms as $roomData) { + // 1) Create Room + $room = StockRoom::create($roomData); + + // 2) Create Line “A” + $line = StockLine::create([ + 'line_name' => 'A', + 'line_symbol' => 'A', + 'room_id' => $room->room_id, + ]); + + // 3) Create Rack “1” + $rack = StockRack::create([ + 'rack_name' => '1', + 'rack_symbol' => '1', + 'line_id' => $line->line_id, + ]); + + // 4) Create Shelf “1” + $shelf = StockShelf::create([ + 'shelf_name' => '1', + 'shelf_symbol' => '1', + 'rack_id' => $rack->rack_id, + ]); + + // 5) Create Position “1” + $position = StockPosition::create([ + 'position_name' => '1', + 'position_symbol' => '1', + 'shelf_id' => $shelf->shelf_id, + ]); + + // 6) (Optional) Create Section “1” + StockSection::create([ + 'section_name' => '1', + 'section_symbol' => '1', + 'capacity' => '100', + 'position_id' => $position->position_id, + ]); + } + } +} diff --git a/init_config_db.sql b/init_config_db.sql index 630edc6..96733c4 100644 --- a/init_config_db.sql +++ b/init_config_db.sql @@ -5,84 +5,199 @@ create table stock_handling field_updated varchar(64) not null, value_prev text not null, value_new text not null, - created_at DATETIME DEFAULT now(), - updated_at DATETIME DEFAULT NULL ON UPDATE now() + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now() ); -create table stock_positions +-- 1) Room: the top‐level container +CREATE TABLE stock_room ( + room_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + room_symbol VARCHAR(15) NOT NULL, + room_name VARCHAR(32) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL ON UPDATE NOW() +); + +-- 2) Line: belongs to a room +CREATE TABLE stock_line ( + line_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + line_symbol VARCHAR(5) NOT NULL, + line_name VARCHAR(32) DEFAULT NULL, + room_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL ON UPDATE NOW(), + FOREIGN KEY (room_id) REFERENCES stock_room(room_id) +); + +-- 3) Rack: belongs to a line +CREATE TABLE stock_rack ( + rack_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + rack_symbol VARCHAR(5) NOT NULL, + rack_name VARCHAR(32) DEFAULT NULL, + line_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL ON UPDATE NOW(), + FOREIGN KEY (line_id) REFERENCES stock_line(line_id) +); + +-- 4) Shelf: belongs to a rack +CREATE TABLE stock_shelf ( + shelf_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + shelf_symbol VARCHAR(5) NOT NULL, + shelf_name VARCHAR(32) DEFAULT NULL, + rack_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL ON UPDATE NOW(), + FOREIGN KEY (rack_id) REFERENCES stock_rack(rack_id) +); + +CREATE TABLE stock_position ( + position_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + position_symbol VARCHAR(5) NOT NULL, + position_name VARCHAR(32) DEFAULT NULL, + shelf_id INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL ON UPDATE NOW(), + FOREIGN KEY (shelf_id) REFERENCES stock_shelf(shelf_id) +); + +CREATE TABLE stock_section ( + section_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + section_symbol VARCHAR(5) NOT NULL, + section_name VARCHAR(32) DEFAULT NULL, + position_id INT NOT NULL, + capacity int not null, + retrievable bool default true, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NULL ON UPDATE NOW(), + FOREIGN KEY (position_id) REFERENCES stock_position(position_id) +); + + + +create table stock_entries2section( + entry_id int not null, + section_id int not null, + count int not null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + PRIMARY KEY (entry_id, section_id) +); + +create table stock_batch ( - id int not null auto_increment primary key, - line varchar(5) not null, - rack varchar(5) not null, - shelf varchar(5) not null, - position varchar(5) not null, - created_at DATETIME DEFAULT now(), - updated_at DATETIME DEFAULT NULL ON UPDATE now() + id int primary key auto_increment, + user_id bigint(20) unsigned not null, + supplier_id int default null, + tracking_number varchar(256) default null, + arrival_date DATETIME DEFAULT null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + FOREIGN KEY (user_id) REFERENCES users (id) ); -create table stock_batch ( - id int not null primary key auto_increment, - supplier_id int unsigned not null, - user_id int not null, - created_at DATETIME DEFAULT now(), - updated_at DATETIME DEFAULT NULL ON UPDATE now() -); - - - create table stock_entries ( id int not null auto_increment primary key, physical_item_id int not null, supplier_id int unsigned not null, - count int default 0 not null, + count int default 0 not null, #needitovatelny ve formulari / jen odpis, inventura + original_count int default 0 not null, price double null, bought date default null, description text null, note text null, - stock_position_id int not null, country_of_origin_id int unsigned not null, - on_the_way bool not null default false, - stock_batch_id int default null, + on_the_way bool not null default false, + stock_batch_id int default null, created_at timestamp default current_timestamp() not null, created_by tinyint unsigned default 1 not null, updated_at timestamp default current_timestamp() not null on update current_timestamp(), - updated_by tinyint unsigned null - + updated_by tinyint unsigned null, + FOREIGN KEY (stock_batch_id) REFERENCES stock_batch (id) ); -create table stock_attributes ( - id int primary key auto_increment, - name varchar(64) not null, +# barvy, apod, original bez loga, v krabicce s potiskem, repliky v krabici bez potisku + +create table stock_attributes +( + id int primary key auto_increment, + name varchar(64) not null, created_at DATETIME DEFAULT now(), updated_at DATETIME DEFAULT NULL ON UPDATE now() ); -create table stock_attributes_translation ( - stock_attributes_id int not null, - language_id int not null, - translated_name varchar(128) not null, - created_at DATETIME DEFAULT now(), - updated_at DATETIME DEFAULT NULL ON UPDATE now() +create table stock_attributes_translation +( + stock_attributes_id int not null, + language_id int not null, + translated_name varchar(128) not null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + FOREIGN KEY (stock_attributes_id) REFERENCES stock_attributes (id), + PRIMARY KEY (stock_attributes_id, language_id) ); -create table stock_attribute_values ( - id int primary key auto_increment, - stock_attribute_id int not null, - name varchar(64) not null, - language_id int not null, - created_at DATETIME DEFAULT now(), - updated_at DATETIME DEFAULT NULL ON UPDATE now() +create table stock_attribute_values +( + id int primary key auto_increment, + stock_attribute_id int not null, + name varchar(64) not null, + language_id int not null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + FOREIGN KEY (stock_attribute_id) REFERENCES stock_attributes (id) ); -create table stock_entry2attributes ( - stock_attributes_id int not null, +create table stock_entry2attributes +( + stock_attributes_id int not null, stock_attribute_value_id int not null, - created_at DATETIME DEFAULT now(), - updated_at DATETIME DEFAULT NULL ON UPDATE now() + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + FOREIGN KEY (stock_attributes_id) REFERENCES stock_attributes (id), + FOREIGN KEY (stock_attribute_value_id) REFERENCES stock_attribute_values (id), + PRIMARY KEY (stock_attributes_id, stock_attribute_value_id) ); +create table stock_batch_files +( + id int primary key auto_increment, + filename text not null, + file_data longblob not null, # object storage + file_type enum ('invoice', 'label', 'other'), + stock_batch_id int not null, + share_location text default null, # direct nahrat sem + user_id bigint(20) unsigned not null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + FOREIGN KEY (stock_batch_id) REFERENCES stock_batch (id), + FOREIGN KEY (user_id) REFERENCES users (id) +); +create table stock_entries_status +( + id int primary key auto_increment, + name varchar(64) not null, #naskladneno, spocitano, presunuto na XY, poskozeno, chybi, nadbyva + description varchar(256) default null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now() +); + +create table stock_entries_status_history +( + id int primary key auto_increment, + stock_entries_id int not null, + section_id int default null, + stock_entries_status_id int not null, + status_note text default null, + created_at DATETIME DEFAULT now(), + updated_at DATETIME DEFAULT NULL ON UPDATE now(), + FOREIGN KEY (stock_entries_id) REFERENCES stock_entries (id), + FOREIGN KEY (section_id) REFERENCES stock_section (section_id), + FOREIGN KEY (stock_entries_status_id) REFERENCES stock_entries_status (id) +); + diff --git a/resources/css/app.css b/resources/css/app.css index 431bc4a..ac5be57 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4,6 +4,45 @@ @plugin '@tailwindcss/typography'; @plugin "daisyui"; + + +@plugin "daisyui/theme" { + name: "insanedestroyer"; + default: false; + prefersdark: false; + color-scheme: "dark"; + --color-base-100: oklch(20% 0.042 265.755); + --color-base-200: oklch(21% 0.034 264.665); + --color-base-300: oklch(27% 0.033 256.848); + --color-base-content: oklch(96% 0.003 264.542); + --color-primary: oklch(44% 0.043 257.281); + --color-primary-content: oklch(98% 0.003 247.858); + --color-secondary: oklch(64% 0.2 131.684); + --color-secondary-content: oklch(98% 0.031 120.757); + --color-accent: oklch(59% 0.145 163.225); + --color-accent-content: oklch(97% 0.021 166.113); + --color-neutral: oklch(37% 0.034 259.733); + --color-neutral-content: oklch(98% 0.002 247.839); + --color-info: oklch(62% 0.214 259.815); + --color-info-content: oklch(97% 0.014 254.604); + --color-success: oklch(72% 0.219 149.579); + --color-success-content: oklch(98% 0.018 155.826); + --color-warning: oklch(70% 0.213 47.604); + --color-warning-content: oklch(98% 0.016 73.684); + --color-error: oklch(63% 0.237 25.331); + --color-error-content: oklch(97% 0.013 17.38); + --radius-selector: 0.25rem; + --radius-field: 2rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; +} + + + @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/jetstream/**/*.blade.php'; diff --git a/resources/js/Components/ModalPDA.tsx b/resources/js/Components/ModalPDA.tsx new file mode 100644 index 0000000..a726ab9 --- /dev/null +++ b/resources/js/Components/ModalPDA.tsx @@ -0,0 +1,28 @@ +// components/Modal.tsx +import React from 'react' + +interface ModalProps { + isOpen: boolean + onClose: () => void + children: React.ReactNode +} + +const ModalPDA: React.FC = ({ isOpen, onClose, children }) => { + if (!isOpen) return null + + return ( +
+
+ + {children} +
+
+ ) +} + +export default ModalPDA diff --git a/resources/js/Components/Tile.tsx b/resources/js/Components/Tile.tsx new file mode 100644 index 0000000..a6a4215 --- /dev/null +++ b/resources/js/Components/Tile.tsx @@ -0,0 +1,22 @@ +// Tile.tsx +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import type { IconProp } from '@fortawesome/fontawesome-svg-core' + +interface TileProps { + title: string + icon: IconProp + onClick: () => void +} + +const Tile: React.FC = ({ title, icon, onClick }) => ( +
+ + {title} +
+) + +export default Tile diff --git a/resources/js/Components/TileLayout.tsx b/resources/js/Components/TileLayout.tsx new file mode 100644 index 0000000..babcfb7 --- /dev/null +++ b/resources/js/Components/TileLayout.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +interface TileLayoutProps { + children: React.ReactNode +} + +const TileLayout: React.FC = ({ children }) => ( +
+ {children} +
+) + +export default TileLayout diff --git a/resources/js/Components/modals/CountStockModal.tsx b/resources/js/Components/modals/CountStockModal.tsx new file mode 100644 index 0000000..6fcc342 --- /dev/null +++ b/resources/js/Components/modals/CountStockModal.tsx @@ -0,0 +1,49 @@ +// components/modals/CountStockModal.tsx +import React from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' + +interface Props { + onClose: () => void +} + +const CountStockModal: React.FC = ({ onClose }) => { + const [quantity, setQuantity] = React.useState(0) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await axios.post('/api/set-stock', { quantity }, { withCredentials: true }) + toast.success('Stock set!') + onClose() + } catch { + toast.error('Failed to set stock') + } + } + + return ( +
+

Set Stock

+ + setQuantity(+e.target.value)} + className="input input-bordered w-full" + required + /> +
+ + +
+
+ ) +} + +export default CountStockModal diff --git a/resources/js/Components/modals/OtherReplacementModal.tsx b/resources/js/Components/modals/OtherReplacementModal.tsx new file mode 100644 index 0000000..b8b8ebd --- /dev/null +++ b/resources/js/Components/modals/OtherReplacementModal.tsx @@ -0,0 +1,49 @@ +// components/modals/OtherReplacementModal.tsx +import React from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' + +interface Props { + onClose: () => void +} + +const OtherReplacementModal: React.FC = ({ onClose }) => { + const [quantity, setQuantity] = React.useState(0) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await axios.post('/api/set-stock', { quantity }, { withCredentials: true }) + toast.success('Stock set!') + onClose() + } catch { + toast.error('Failed to set stock') + } + } + + return ( +
+

Set Stock

+ + setQuantity(+e.target.value)} + className="input input-bordered w-full" + required + /> +
+ + +
+
+ ) +} + +export default OtherReplacementModal diff --git a/resources/js/Components/modals/SetStockModal.tsx b/resources/js/Components/modals/SetStockModal.tsx new file mode 100644 index 0000000..5935a48 --- /dev/null +++ b/resources/js/Components/modals/SetStockModal.tsx @@ -0,0 +1,49 @@ +// components/modals/SetStockModal.tsx +import React from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' + +interface Props { + onClose: () => void +} + +const SetStockModal: React.FC = ({ onClose }) => { + const [quantity, setQuantity] = React.useState(0) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await axios.post('/api/set-stock', { quantity }, { withCredentials: true }) + toast.success('Stock set!') + onClose() + } catch { + toast.error('Failed to set stock') + } + } + + return ( +
+

Set Stock

+ + setQuantity(+e.target.value)} + className="input input-bordered w-full" + required + /> +
+ + +
+
+ ) +} + +export default SetStockModal diff --git a/resources/js/Pages/BatchCounting.tsx b/resources/js/Pages/BatchCounting.tsx new file mode 100644 index 0000000..18ba11b --- /dev/null +++ b/resources/js/Pages/BatchCounting.tsx @@ -0,0 +1,243 @@ +// resources/js/Pages/BatchCounting.tsx +import React, { useMemo, useState } from 'react' +import { Head, usePage } from '@inertiajs/react' +import AppLayout from '@/Layouts/AppLayout' +import axios from 'axios' +import { toast, Toaster } from 'react-hot-toast' + +import { + MaterialReactTable, + type MRT_ColumnDef, +} from 'material-react-table' + +import { StockEntry, StockBatch } from '@/types' + +const statusMapping = { + CORRECT: 2, // NEW_GOODS_COUNTED + MISSING_ITEMS: 7, // NEW_GOODS_MISSING + BROKEN_ITEMS: 6, // NEW_GOODS_DAMAGED +} + +export default function BatchCounting() { + const { selectedBatch } = usePage<{ selectedBatch: StockBatch }>().props + + const [entries, setEntries] = useState(selectedBatch.stock_entries) + + // pin items without any of (2,6,7) to top + const data = useMemo(() => { + const noStatus: StockEntry[] = [] + const withStatus: StockEntry[] = [] + selectedBatch.stock_entries.forEach((e) => { + const has = e.status_history.some(h => + [2, 6, 7].includes(h.stock_entries_status_id) + ) + if (has) withStatus.push(e) + else noStatus.push(e) + }) + return [...noStatus, ...withStatus] + }, [selectedBatch.stock_entries]) + + const getLatestRelevant = (history: any[]) => { + const relevant = history + .filter(h => [2, 6, 7].includes(h.stock_entries_status_id)) + .sort( + (a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + return relevant.length ? relevant[relevant.length - 1] : null; + }; + + const columns = useMemo[]>(() => [ + { + accessorKey: 'status', + header: 'Status', + size: 60, + Cell: ({ row }) => { + const latest = getLatestRelevant(row.original.status_history); + if (!latest) return null; + switch (latest.stock_entries_status_id) { + case 2: + return ; + case 6: + return ⚠️; + case 7: + return ✖️; + default: + return null; + } + }, + }, + { accessorFn: e => e.id, header: 'ID' }, + { accessorFn: e => e.physical_item.name, header: 'Item' }, + { accessorFn: e => e.supplier.name, header: 'Supplier' }, + { accessorFn: e => e.quantity, header: 'Qty' }, + ], []) + + // modal state + const [modalOpen, setModalOpen] = useState(false) + const [selectedEntry, setSelectedEntry] = useState(null) + const [selectedStatus, setSelectedStatus] = useState(null) + const [count, setCount] = useState(0) + + const openModal = (entry: StockEntry) => { + setSelectedEntry(entry) + setSelectedStatus(null) + setCount(0) + setModalOpen(true) + } + + const submitStatus = async () => { + if (!selectedEntry || !selectedStatus) return + + try { + const response = await axios.post(`/api/stockActions/${selectedEntry.id}/status`, { + status: statusMapping[selectedStatus], + count: selectedStatus === 'CORRECT' ? undefined : count, + }) + + const newHist: typeof selectedEntry.status_history[0] = response.data.history + + // 2. Append the new history into our entries state + setEntries(prev => + prev.map((e) => + e.id === selectedEntry.id + ? { ...e, status_history: [...e.status_history, newHist] } + : e + ) + ) + + toast.success('Status updated successfully!') + setModalOpen(false) + } catch (error: any) { + console.error('Failed to update status:', error) + toast.error( + error.response?.data?.message || + 'Failed to update status. Please try again.' + ) + } + } + + return ( + ( +

+ Stock Entries +

+ )} + > + + + +
+

+ Batch: {selectedBatch.name} +

+ + !e.status_history.some(h => [2,6,7].includes(h.stock_entries_status_id))), + ...entries.filter(e => e.status_history.some(h => [2,6,7].includes(h.stock_entries_status_id))), + ]} + enableRowSelection={false} + muiTableBodyRowProps={({ row }) => { + const latest = getLatestRelevant(row.original.status_history); + let bgColor: string | undefined; + if (latest) { + switch (latest.stock_entries_status_id) { + case 2: + bgColor = 'rgba(220, 253, 213, 0.5)'; // green-50 + break; + case 6: + bgColor = 'rgba(255, 247, 237, 0.5)'; // orange-50 + break; + case 7: + bgColor = 'rgba(254, 226, 226, 0.5)'; // red-50 + break; + } + } + return { + onClick: () => openModal(row.original), + sx: { + cursor: 'pointer', + backgroundColor: bgColor, + }, + }; + }} + /> + + {modalOpen && selectedEntry && ( +
+
+

+ Update "{selectedEntry.physical_item.name}" +

+ +
+ + + +
+ + {selectedStatus && selectedStatus !== 'CORRECT' && ( +
+ + setCount(Number(e.target.value))} + /> +
+ )} + +
+ + +
+
+
+ )} +
+
+ ) +} diff --git a/resources/js/Pages/PdaView.tsx b/resources/js/Pages/PdaView.tsx new file mode 100644 index 0000000..e0a0e76 --- /dev/null +++ b/resources/js/Pages/PdaView.tsx @@ -0,0 +1,185 @@ +import React from 'react' +import axios from 'axios' +import { Head, usePage } from '@inertiajs/react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faArrowLeft, + faQuestionCircle, + faBoxOpen, + faClipboardList, + faCubes, + faPlus, +} from '@fortawesome/free-solid-svg-icons' +import { toast, Toaster } from 'react-hot-toast' + +import Tile from '../Components/Tile' +import TileLayout from '../Components/TileLayout' +import ModalPDA from '../Components/ModalPDA' + +// your extracted modal forms: +import SetStockModal from '../Components/modals/SetStockModal' +import OtherReplacementModal from '../Components/modals/OtherReplacementModal' +import CountStockModal from '../Components/modals/CountStockModal' + +type Role = 'Expedice' | 'Skladnik' + +// actions per role +const roleActions: Record = { + Expedice: ['stockSectionScanned', 'labelScanned'], + Skladnik: ['batchScan', 'stockScan'], +} + +// configuration for each tile: either opens a modal (modalKey), +// or performs an API call (onClick) +type TileConfig = { + title: string + icon: any + modalKey?: ModalKey + onClick?: () => void +} + +const tilesConfig: Record> = { + Expedice: { + stockSectionScanned: [ + { title: 'Not Present', icon: faBoxOpen, onClick: () => toast('Not Present clicked') }, + { title: 'Na pozici je jiny ovladac', icon: faBoxOpen, onClick: () => toast('Not Present clicked') }, + { + title: 'Present but Shouldn’t', + icon: faClipboardList, + onClick: async () => { + // example direct axios call + try { + await axios.post('/api/presence-error', {}, { withCredentials: true }) + toast.success('Reported!') + } catch { + toast.error('Failed to report') + } + }, + }, + { title: 'Other Replacement (na test/one time)', icon: faPlus, modalKey: 'otherReplacement' }, + { title: 'Pridej vazbu na jiny ovladac', icon: faPlus, onClick: () => toast('Batch Info clicked') }, + { title: 'Pridej docasnou vazbu (nez prijde jine zbozi, i casove omezene, nejnizsi priorita)', icon: faPlus, onClick: () => toast('Batch Info clicked') }, + { title: 'Doplnit zbozi / dej vic kusu', icon: faPlus, onClick: () => toast('Batch Info clicked') }, + { title: 'Report - chci zmenit pozici(bliz / dal od expedice)', icon: faPlus, onClick: () => toast('Batch Info clicked') }, + { title: 'Pultovy prodej', icon: faPlus, onClick: () => toast('- naskenuju na webu / naskenuju na skladovem miste, obj. se udela automaticky, zakaznik si muze v rohu na PC vyplnit osobni udaje na special formulari - pak customera prida obsluha do obj') }, + ], + labelScanned: [ + { title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + ] + }, + Skladnik: { + batchScan: [ + { title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + { title: 'Set Stock', icon: faCubes, modalKey: 'setStock' }, + { title: 'Count Stock', icon: faPlus, modalKey: 'countStock' }, + { title: 'Stitkovani (male stitky)', icon: faClipboardList, onClick: () => toast('Stitkovani (male stitky)') }, + { title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + { title: 'Tisk QR kod na krabice', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + ], + stockScan: [ + { title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + { title: 'Zmena skladoveho mista (i presun jen casti kusu)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + { title: 'Uprava skladoveho mista (mene sekci, apod)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + { title: 'Zmena poctu', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + { title: 'Discard (odebrat ze skladoveho mista / posilame zpet)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + ], + others: [ + { title: 'Nove skladove misto', icon: faClipboardList, onClick: () => toast('Batch Info clicked') }, + ] + }, +} + +type ModalKey = 'setStock' | 'otherReplacement' | 'countStock' | null + +export default function PdaView() { + const { + auth: { user }, + } = usePage<{ auth: { user: { role: string } } }>().props + + const [role, setRole] = React.useState( + user.role === 'admin' ? 'Expedice' : (user.role as Role) + ) + const [action, setAction] = React.useState('') + const [activeModal, setActiveModal] = React.useState(null) + + const isAdmin = true + // const isAdmin = user.role === 'admin' + const tabs: Role[] = ['Expedice', 'Skladnik'] + + const closeModal = () => setActiveModal(null) + + return ( + <> + + + {/* Top bar */} +
+ + Back + +
+ + {/* Admin tabs */} + {isAdmin && ( +
+ {tabs.map((r) => ( + + ))} +
+ )} + + {/* Action selectors */} +
+ {[...(roleActions[role] || []), 'clear'].map((act) => ( + + ))} +
+ + {/* Tiles */} + + {action && + (tilesConfig[role][action] || []).map(({ title, icon, onClick, modalKey }) => ( + { + if (modalKey) setActiveModal(modalKey) + else if (onClick) onClick() + }} + /> + ))} + + + {/* Single Modal */} + + {activeModal === 'setStock' && } + {activeModal === 'otherReplacement' && } + {activeModal === 'countStock' && } + + + + + ) +} diff --git a/resources/js/Pages/StockBatch.tsx b/resources/js/Pages/StockBatch.tsx new file mode 100644 index 0000000..5ae9be5 --- /dev/null +++ b/resources/js/Pages/StockBatch.tsx @@ -0,0 +1,711 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { Head } from '@inertiajs/react'; +import AppLayout from '@/Layouts/AppLayout'; +import axios from 'axios'; +import { toast, Toaster } from 'react-hot-toast'; +import { + MaterialReactTable, + type MRT_ColumnDef, + type MRT_PaginationState, + type MRT_SortingState, +} from 'material-react-table'; +import { Combobox } from '@headlessui/react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import {faCheck, faQrcode, faListOl} from '@fortawesome/free-solid-svg-icons'; +import {StockBatch, StockSection, Supplier} from '@/types'; +import {size} from "lodash"; +import { router } from "@inertiajs/react"; +interface DropdownOption { id: number; name: string } +interface FileWithType { file: File; fileType: 'invoice' | 'label' | 'other' } + +interface StockEntry { + id: number; + physical_item_id: number | null; + supplier_id: number | null; + count: number; + price: number | null; + bought: string | null; + description: string | null; + note: string | null; + stock_position_id: number | null; + country_of_origin_id: number | null; + on_the_way: boolean; + stock_batch_id: number; + physical_item?: DropdownOption; + supplier?: DropdownOption; + stock_position?: { id: number; line: string; rack: string; shelf: string; position: string }; + sections?: (StockSection & { pivot: { section_id:number; count: number; created_at: string; updated_at: string | null } })[]; +} + +const defaultBatchForm = { supplierId: null as number | null, tracking_number: '', arrival_date: '' }; +const defaultEntryForm: Omit = { + physical_item_id: null, + supplier_id: null, + count: 0, + price: null, + bought: null, + description: null, + note: null, + country_of_origin_id: null, + on_the_way: false, + surplus_item: false, + stock_batch_id: null, +}; + +export default function StockBatches() { + const [batches, setBatches] = useState([]); + const [statuses, setStatuses] = useState<[]>([]); + const [suppliers, setSuppliers] = useState([]); + const [batchLoading, setBatchLoading] = useState(false); + const [batchCount, setBatchCount] = useState(0); + const [batchPagination, setBatchPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [batchSorting, setBatchSorting] = useState([{ id: 'updatedAt', desc: true }]); + const [batchFilter, setBatchFilter] = useState(''); + + const createDialogRef = useRef(null); + const [batchForm, setBatchForm] = useState(defaultBatchForm); + const [batchFiles, setBatchFiles] = useState([]); + + const viewDialogRef = useRef(null); + const [selectedBatch, setSelectedBatch] = useState(null); + const [entries, setEntries] = useState([]); + const [entriesLoading, setEntriesLoading] = useState(false); + const [entriesCount, setEntriesCount] = useState(0); + const [entriesPagination, setEntriesPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [entriesSorting, setEntriesSorting] = useState([{ id: 'id', desc: false }]); + const [entriesFilter, setEntriesFilter] = useState(''); + + const [onTheWayEntries, setOnTheWayEntries] = useState([]); + const [entriesOnTheWayLoading, setEntriesOnTheWayLoading] = useState(false); + const [onTheWayEntriesCount, setOnTheWayEntriesCount] = useState(0); + const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [onTheWayEntriesSorting, setOnTheWayEntriesSorting] = useState([{ id: 'id', desc: false }]); + const [onTheWayEntriesFilter, setOnTheWayEntriesFilter] = useState(''); + + const [onTheWayEntriesSelections, setOnTheWayEntriesSelections] = useState([]); + + const entryDialogRef = useRef(null); + const [editingEntry, setEditingEntry] = useState(null); + const [entryForm, setEntryForm] = useState>({ ...defaultEntryForm }); + + const [physicalItems, setPhysicalItems] = useState([]); + const [positions, setPositions] = useState([]); + const [countries, setCountries] = useState([]); + + const [itemQuery, setItemQuery] = useState(''); + const filteredItems = useMemo( + () => itemQuery === '' ? physicalItems : physicalItems.filter(i => i.name.toLowerCase().includes(itemQuery.toLowerCase())), + [itemQuery, physicalItems] + ); + + // Add this state alongside your other hooks: + const [positionRows, setPositionRows] = useState<{ + stock_position_id: number | null; + count: number | null; + }[]>([{ stock_position_id: null, count: null }]); + +// Handler to update a row + const handlePositionRowChange = (index: number, field: 'stock_position_id' | 'count', value: number | null) => { + setPositionRows(prev => { + const rows = prev.map((r, i) => i === index ? { ...r, [field]: value } : r); + // if editing the last row's count, and it's a valid number, append a new empty row + if (field === 'count' && index === prev.length - 1 && value && value > 0) { + rows.push({ stock_position_id: null, count: null }); + } + return rows; + }); + }; + + const formatDate = (value: string, withTime = true) => { + const d = new Date(value); + const dd = String(d.getDate()).padStart(2, '0'); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const yyyy = d.getFullYear(); + if (!withTime) return `${dd}-${mm}-${yyyy}`; + const hh = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + return `${dd}-${mm}-${yyyy} ${hh}:${mi}`; + }; + + const batchColumns = useMemo[]>(() => [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'supplier.name', header: 'Supplier' }, + { accessorKey: 'tracking_number', header: 'Tracking #' }, + { accessorKey: 'arrival_date', header: 'Arrival Date', Cell: ({ cell }) => formatDate(cell.getValue(), false) }, + { accessorFn: r => r.files?.length ?? 0, id: 'files', header: 'Files' }, + { accessorFn: r => r.stock_entries?.length ?? 0, id: 'items', header: 'Items' }, + { accessorKey: 'created_at', header: 'Created', Cell: ({ cell }) => formatDate(cell.getValue()) }, + { accessorKey: 'updated_at', header: 'Updated', Cell: ({ cell }) => formatDate(cell.getValue()) }, + ], []); + + const entryColumns = useMemo[]>( + () => [ + { accessorKey: 'id', header: 'ID', size: 80 }, + { accessorKey: 'physical_item.name', header: 'Physical Item', size: 200 }, + { accessorKey: 'supplier.name', header: 'Supplier', size: 150 }, + { accessorKey: 'physical_item.manufacturer.name', header: 'Brand', size: 150 }, + { accessorKey: 'count', header: 'Count', size: 100 }, + { accessorKey: 'price', header: 'Price', size: 100, Cell: ({ cell }) => cell.getValue()?.toFixed(2) || '-' }, + { accessorKey: 'bought', header: 'Bought Date', size: 120 }, + { accessorKey: 'on_the_way', header: 'On The Way', size: 100, Cell: ({ cell }) => cell.getValue() ? 'Yes' : 'No' }, + { accessorKey: 'stock_position', header: 'Position', size: 150, Cell: ({ row }) => { + const pos = row.original.stock_position; + return pos ? `${pos.line}-${pos.rack}-${pos.shelf}-${pos.position}` : '-'; + } + }, + { accessorKey: 'updated_at', header: 'Last Updated', size: 150 }, + ], + [], + ); + + const entryOnTheWayColumns = useMemo[]>( + () => [ + { + accessorKey: 'select', + header: 'Select', + Cell: ({ row }) => { + const id = row.original.id; + return ( + setOnTheWayEntriesSelections(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] + )} + /> + ); + }, + }, + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'physical_item.name', header: 'Item' }, + { accessorKey: 'supplier.name', header: 'Supplier' }, + ], [onTheWayEntriesSelections] + ); + + useEffect(() => { fetchOptions(); fetchEntriesOnTheWay(); fetchStatusList();}, []); + useEffect(() => { fetchBatches(); }, [batchPagination, batchSorting, batchFilter]); + useEffect(() => { if (selectedBatch) fetchEntries(selectedBatch.id); }, [entriesPagination, entriesSorting, entriesFilter]); + + async function fetchSuppliers() { + try { const res = await axios.get('/api/stockBatches/options'); setSuppliers(res.data.suppliers); } + catch { toast.error('Cannot load suppliers'); } + } + + async function fetchOptions() { + try { + const res = await axios.get('/api/stockData/options'); + setSuppliers(res.data.suppliers); + setPositions(res.data.stockPositions); + console.log(res.data.stockPositions); + setCountries(res.data.countriesOrigin); + } catch { toast.error('Cannot load entry options'); } + } + + async function fetchBatches() { + setBatchLoading(true); + try { + const res = await axios.get('/api/stockBatches', { params: { page: batchPagination.pageIndex+1, perPage: batchPagination.pageSize, sortField: batchSorting[0].id, sortOrder: batchSorting[0].desc?'desc':'asc', filter: batchFilter } }); + console.log(res.data.data);setBatches(res.data.data); setBatchCount(res.data.meta.total); + } catch { toast.error('Cannot fetch batches'); } + finally { setBatchLoading(false); } + } + + async function fetchEntries(batchId: number) { + setEntriesLoading(true); + console.log("fetching entries for batch ", batchId); + try { + const res = await axios.get(`/api/stockBatches/${batchId}/entries`, { params: { page: entriesPagination.pageIndex+1, perPage: entriesPagination.pageSize, sortField: entriesSorting[0].id, sortOrder: entriesSorting[0].desc?'desc':'asc', filter: entriesFilter } }); + console.log(res.data.data);setEntries(res.data.data); setEntriesCount(size(res.data.data)); + } catch (error) { toast.error('Cannot fetch entries'); console.error(error); } + finally { setEntriesLoading(false); } + } + + async function fetchEntriesOnTheWay() { + setEntriesOnTheWayLoading(true); + console.log("fetching entries on the way "); + try { + const res = await axios.get(`/api/stockDataOnTheWay`); + console.log(res.data.data); setOnTheWayEntries(res.data.data); setOnTheWayEntriesCount(size(res.data.data)); + } catch (error) { toast.error('Cannot fetch entries'); console.error(error); } + finally { setEntriesOnTheWayLoading(false); } + } + + const openCreate = () => createDialogRef.current?.showModal(); + const closeCreate = () => { createDialogRef.current?.close(); setBatchForm(defaultBatchForm); setBatchFiles([]); }; + + const openView = (batch: StockBatch) => { setSelectedBatch(batch); fetchEntries(batch.id); viewDialogRef.current?.showModal(); }; + const closeView = () => { viewDialogRef.current?.close(); setEntries([]); } + + const openEntry = (entry?: StockEntry) => { + fetchEntriesOnTheWay(); + + if (entry) { + setEditingEntry(entry); + setEntryForm({ ...entry }); + + // Build rows from whatever pivot’d sections came back + const rows = entry.sections?.map(sec => ({ + stock_position_id: sec.pivot.section_id, + count: sec.pivot.count, + })) || []; + console.log(entry.sections); + + // Append an empty row so the user can add more + setPositionRows([...rows, { stock_position_id: null, count: null }]); + + } else { + setEditingEntry(null); + setEntryForm({ ...defaultEntryForm, stock_batch_id: selectedBatch!.id }); + // brand-new: start with one empty row + setPositionRows([{ stock_position_id: null, count: null }]); + } + + entryDialogRef.current?.showModal(); + }; + const closeEntry = () => { entryDialogRef.current?.close(); setEditingEntry(null); }; + + const handleBatchInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setBatchForm(prev => ({ ...prev, [name]: name === 'supplierId' ? (value ? parseInt(value) : null) : value })); + }; + + const handleBatchFileChange = (e: React.ChangeEvent) => { + if (e.target.files) { + setBatchFiles(Array.from(e.target.files).map(file => ({ file, fileType: 'other' }))); + } + }; + + const handleBatchFileTypeChange = (idx: number, type: FileWithType['fileType']) => { + setBatchFiles(prev => prev.map((f, i) => i === idx ? { ...f, fileType: type } : f)); + }; + + const removeBatchFile = (idx: number) => { + setBatchFiles(prev => prev.filter((_, i) => i !== idx)); + }; + + const handleBatchSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const fd = new FormData(); + if (batchForm.supplierId) fd.append('supplier_id', batchForm.supplierId.toString()); + if (batchForm.tracking_number) fd.append('tracking_number', batchForm.tracking_number); + if (batchForm.arrival_date) fd.append('arrival_date', batchForm.arrival_date); + batchFiles.forEach(({ file, fileType }, i) => { fd.append(`files[${i}]`, file); fd.append(`file_types[${i}]`, fileType); }); + await axios.post('/api/stockBatches', fd, { headers: { 'Content-Type': 'multipart/form-data' } }); + toast.success('Batch created'); closeCreate(); fetchBatches(); + } catch (err: any) { + if (axios.isAxiosError(err) && err.response?.status === 422) { + Object.values(err.response.data.errors).flat().forEach((m: string) => toast.error(m)); + } else { + toast.error('Failed to create batch'); + } + } + }; + + const handleBatchAddEntries = async (e: React.FormEvent) => { + e.preventDefault(); + console.log(selectedBatch); + try { + await axios.put(`/api/stockBatches/${selectedBatch.id}/entries`, { ids: onTheWayEntriesSelections }); + toast.success('Batch entries updated successfully'); + closeEntry(); + fetchBatches(); + setSelectedBatch(null); + setOnTheWayEntriesSelections([]); + closeView(); + } catch { + toast.error('Batch entries update failed'); + } + }; + + const handleEntryInputChange = (e: React.ChangeEvent) => { + const { name, value, type, checked } = e.target; + setEntryForm(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : type === 'number' ? parseFloat(value) || null : value || null, + } as any)); + }; + + const handleEntrySubmit = async (e: React.FormEvent) => { + + e.preventDefault(); + try { + if (editingEntry) await axios.put(`/api/stockData/${editingEntry.id}`, { entryForm, sections: positionRows.filter(r => r.stock_position_id && r.count)}); + else await axios.post(`/api/stockData`, entryForm); + toast.success(editingEntry ? 'Entry updated' : 'Entry created'); + fetchEntries(selectedBatch!.id); closeEntry(); + } catch { toast.error('Cannot save entry'); } + }; + + // Before your return JSX, replace the old flag with this: + const hasNonCountedStatus = selectedBatch?.stock_entries?.some(stockEntry => { + // 1. Only statuses with no section: + const nullSectionStatuses = stockEntry.status_history?.filter(h => h.section_id === null) ?? []; + if (nullSectionStatuses.length === 0) return true; + // 2. Find the *latest* one by timestamp: + const latest = nullSectionStatuses.reduce((prev, curr) => + new Date(prev.created_at) > new Date(curr.created_at) ? prev : curr + ); + // 3. Check if that status isn’t “COUNTED” (id === 2) + return latest.stock_entries_status_id !== 2; + }); + + function calculateStatusRatio( + entries: StockEntry[], + statusId: number + ): { count: number; total: number; ratio: number } { + const total = entries.length; + const count = entries.filter((entry) => + entry.status_history?.some((h) => h.stock_entries_status_id === statusId) + ).length; + const ratio = total > 0 ? count / total : 0; + return { count, total, ratio }; + } + + + async function fetchStatusList() { + try { + const res = await axios.get('/api/stockStatusList'); + setStatuses(res.data.statuses); + } catch { + toast.error('Failed to load items'); + } + } + + async function fetchPhysicalItems(query: string) { + try { + const res = await axios.get('/api/stockData/options/items', { params: { item_name: query } }); + setPhysicalItems(res.data.physicalItems); + } catch { + toast.error('Failed to load items'); + } + } + useEffect(() => { + const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500); + return () => clearTimeout(delay); + }, [itemQuery]); + + const statusIds = [2, 6, 7, 8] + + return ( +

Stock Batches

}> + + + +
+
+
+
+

Batches

+ +
+ ({ onClick: () => openView(row.original), style: { cursor: 'pointer' } })} + /> +
+
+
+ + +
+ +

New Stock Batch

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {batchFiles.map((f, i) => ( +
+ {f.file.name} + + +
+ ))} + +
+ +
+
+
+ + +
+ +
+ +
+

Batch Details

+ {selectedBatch &&
    +
  • ID: {selectedBatch.id}
  • +
  • Supplier: {selectedBatch.supplier.name}
  • +
  • Tracking #: {selectedBatch.tracking_number}
  • +
  • Arrival: {formatDate(selectedBatch.arrival_date, false)}
  • +
} +
+ {/* existing status-history list */} + {/*{selectedBatch?.stock_entries?.map((entry) => (*/} + {/*
*/} + {/*

Stock Entry {entry.id}

*/} + {/*
    */} + {/* {entry.status_history?.map((history, idx) => (*/} + {/*
  • {history.status.name}
  • */} + {/* ))}*/} + {/*
*/} + {/*
*/} + {/*))}*/} + + {/* ratio displays */} +
+

Status Ratios

+ {selectedBatch && statusIds.map((id) => { + const { count, total, ratio } = calculateStatusRatio(selectedBatch.stock_entries, id); + const status_data = statuses.filter(s => s.id === id)[0]; + return ( +

+ {status_data.name}: {count} / {total} ( + {(ratio * 100).toFixed(1)}%) +

+ ); + })} +
+
+
+
+
+ +
+ {hasNonCountedStatus && ( +
+ +
+ )} + + +
+
+
+

Stock Entries

+
+ ({ onClick: () => openEntry(row.original), style: { cursor: 'pointer' }})} + /> +
+
+
+ +
+
+ +
+ +
+
+
+
+ + +
+ + + {!editingEntry && +
+

Select Incoming Items

+ e.on_the_way)} + manualPagination manualSorting enableGlobalFilter + onPaginationChange={setEntriesPagination} + onSortingChange={setEntriesSorting} + onGlobalFilterChange={setEntriesFilter} + rowCount={entriesCount} + state={{ isLoading: entriesOnTheWayLoading, pagination: onTheWayEntriesPagination, sorting: onTheWayEntriesSorting, globalFilter: onTheWayEntriesFilter }} + /> +
+ } + +
+

{editingEntry ? 'Edit Entry' : 'New Entry'}

+ + {!editingEntry && +
+ setEntryForm(prev => ({ ...prev, physical_item_id: val }))}> + setItemQuery(e.target.value)} + displayValue={id => physicalItems.find(i => i.id === id)?.name || ''} + placeholder="Select item..." + className="input" + /> + + {filteredItems.map(item => ( + +
+ {item.name}{entryForm.physical_item_id === item.id && } +
+
+ ))} +
+
+
+ } + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +