diff --git a/.env b/.env index 9a94ab6..13878da 100644 --- a/.env +++ b/.env @@ -12,11 +12,13 @@ DB_HOST=127.0.0.1 DB_PORT=3311 DB_DATABASE=vat_wms DB_DATABASE_WAREHOUSE=vat_warehouse +DB_DATABASE_SHIPPING=shipping DB_USERNAME=vat_wms DB_PASSWORD=vat_wms APP_KEY=base64:yptRdaDOEfbK3K+eheSvnvbD7JFYK/GUedXzVM4U2qs= - +SHIPPING_API_KEY=4|mNZYZOhSB2gcCf0G9vJr38j9im7AtbM5Xj5BdcMr04903057 +SHIPPPING_APP_URL=https://shipping.dalkove-ovladace.cz #OCTANE_SERVER=roadrunner diff --git a/app/Http/Controllers/Api/ExpediceController.php b/app/Http/Controllers/Api/ExpediceController.php new file mode 100644 index 0000000..9d34061 --- /dev/null +++ b/app/Http/Controllers/Api/ExpediceController.php @@ -0,0 +1,109 @@ + env('SHIPPPING_APP_URL'), + 'headers' => [ + 'Authorization' => 'Bearer ' . env('SHIPPING_API_KEY'), + 'Accept' => 'application/json', + ], + // Optionally set a timeout + 'timeout' => 10, + ]); + + // Prepare query parameters + $query = [ + 'user_id' => 5 +// 'user_id' => $request->user()->id, + ]; + + // Send GET request to /api/batchListWMS with user_id as query parameter + $response = $client->request('GET', '/api/batchListWMS', [ + 'query' => $query, + ]); + + $batches = json_decode($response->getBody()->getContents(), true); + + // Return decoded JSON (or raw body, depending on your needs) + return response()->json([ + "batches" => $batches['batches'], + ]); +// return json_decode($response->getBody()->getContents(), true); + } + + public function expediceListWMS(Request $request) { + // Instantiate Guzzle client + $client = new Client([ + 'base_uri' => env('SHIPPPING_APP_URL'), + 'headers' => [ + 'Authorization' => 'Bearer ' . env('SHIPPING_API_KEY'), + 'Accept' => 'application/json', + ], + // Optionally set a timeout + 'timeout' => 40, + ]); + + // Prepare payload + $payload = [ + 'batch_id' => $request->input('batch_id'), // or wherever you pull batch_id from + ]; + + // Send POST request with JSON body + $response = $client->request('POST', '/api/warehouseExpediceListWMS', [ + 'json' => $payload, + ]); + + $batch_items = json_decode($response->getBody()->getContents(), true); + + + // Return decoded JSON (or raw body, depending on your needs) + return response()->json([ + "batch_items" => $batch_items, + ]); +// return json_decode($response->getBody()->getContents(), true); + } + + public function getProductImage(Request $request) { + // Instantiate Guzzle client + $client = new Client([ + 'base_uri' => env('SHIPPPING_APP_URL'), + 'headers' => [ + 'Authorization' => 'Bearer ' . env('SHIPPING_API_KEY'), + 'Accept' => 'application/json', + ], + // Optionally set a timeout + 'timeout' => 40, + ]); + + // Prepare query parameters instead of JSON body + $query = [ + 'selectedShipmentID' => $request->input('selectedShipmentID'), + ]; + +// Send GET request with query parameters + $response = $client->request('GET', '/api/warehouseExpedice/getImage', [ + 'query' => $query, + ]); + + $batch_items = json_decode($response->getBody()->getContents(), true); + + + // Return decoded JSON (or raw body, depending on your needs) + return response()->json($batch_items); +// return json_decode($response->getBody()->getContents(), true); + } + +} diff --git a/app/Http/Controllers/Api/ScannerController.php b/app/Http/Controllers/Api/ScannerController.php new file mode 100644 index 0000000..0657828 --- /dev/null +++ b/app/Http/Controllers/Api/ScannerController.php @@ -0,0 +1,155 @@ +validate([ + 'barcode_type' => 'required|string|in:stock_batch,stock_section,stock_position,label_scanned,carrier_scanned', + 'payload' => 'required|array', + 'payload.id' => 'required|integer|min:1', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + $type = $data['barcode_type']; + $id = $data['payload']['id']; + + try { + switch ($type) { + // ───────────────────────────────────────────────────────────── + case 'stock_batch': + // Attempt to load a StockBatch by ID. Adjust with(...) if you have relationships you want to return. + $batch = StockBatch::with(['stockEntries.physicalItem', 'stockEntries.sections.position.shelf.rack.line.room', 'stockEntries.supplier', 'files', 'supplier']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $batch, + ], 200); + + // ───────────────────────────────────────────────────────────── + case 'stock_section': + // Attempt to load a StockSection by ID. + $section = StockSection::with(['position.sections.entries.physicalItem', 'entries.physicalItem']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $section, + ], 200); + + // ───────────────────────────────────────────────────────────── + case 'stock_position': + // Attempt to load a StockPosition by ID. + $position = StockPosition::with(['shelf', 'sections.entries.physicalItem']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $position, + ], 200); + + // ───────────────────────────────────────────────────────────── + case 'label_scanned': + // Attempt to load a Label by ID. Replace with your real model/relations. + $label = Label::with(['product', 'batch']) + ->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $label, + ], 200); + + // ───────────────────────────────────────────────────────────── + case 'carrier_scanned': + // “Do something” for carrier scans. For example, mark the carrier as checked‐in. + // Here we just look up a Carrier and flip a boolean. Adjust to your real logic. + $carrier = Carrier::findOrFail($id); + $carrier->last_scanned_at = now(); + $carrier->save(); + + return response()->json([ + 'success' => true, + ], 200); + + // ───────────────────────────────────────────────────────────── + default: + // This will never run because our validator already limits barcode_type to the four values. + return response()->json([ + 'success' => false, + 'error' => 'invalid_barcode_type', + ], 400); + } + } catch (ModelNotFoundException $e) { + // Any of the findOrFail(...) calls threw a ModelNotFoundException + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => "No record found for ID {$id} under type '{$type}'.", + ], 404); + + } catch (\Exception $e) { + // Log the exception so you can inspect it later + Log::error('Error in BarcodeController@scan', [ + 'barcode_type' => $type, + 'id' => $id, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } + + public function batchBarcodePrint(Request $request) { + + $code = Encoder::encode(json_encode( + [ + "barcode_type" => "stock_batch", + "payload" => [ + 'id' => $request->input('id'), + ] + ] + )); + $renderer = new PngRenderer(); + + $aztec_code = $renderer->render($code); + + $base64_aztec_img = base64_encode($aztec_code); + $base64_aztec_src = "data:image/png;base64,{$base64_aztec_img}"; + return response()->json([ + 'success' => true, + 'data' => $base64_aztec_src, + ], 200); + } + +} diff --git a/app/Http/Controllers/Api/StockBatchController.php b/app/Http/Controllers/Api/StockBatchController.php index 9e35674..590cb7e 100644 --- a/app/Http/Controllers/Api/StockBatchController.php +++ b/app/Http/Controllers/Api/StockBatchController.php @@ -24,7 +24,7 @@ class StockBatchController extends Controller public function index(Request $request) { $query = StockBatch::query() - ->with(['user', 'supplier', 'stockEntries.statusHistory.status', 'files']); + ->with(['user', 'supplier', 'stockEntries.statusHistory.status', 'stockEntries.sections','stockEntries.physicalItem', 'files']); // Apply filters if provided if ($request->has('search')) { @@ -33,6 +33,7 @@ class StockBatchController extends Controller $q->where('name', 'like', "%{$search}%"); }); } + $query->where('default_batch', 0); // Sort $sortField = $request->input('sort_field', 'updated_at'); @@ -65,12 +66,14 @@ class StockBatchController extends Controller $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', + 'supplier_id' => 'nullable|integer', + 'tracking_number' => 'nullable|string', + 'arrival_date' => 'nullable|date', + 'files.*' => 'file' + .'|mimes:jpeg,png,jpg,heic,heif,pdf,doc,docx,xls,xlsx,txt' + .'|max:20480', // max size in KB (20 MB) + 'file_types' => 'array', + 'file_types.*' => 'in:invoice,label,other', ]); if ($validator->fails()) { diff --git a/app/Http/Controllers/Api/StockEntryController.php b/app/Http/Controllers/Api/StockEntryController.php index 4f1b4c8..420195c 100644 --- a/app/Http/Controllers/Api/StockEntryController.php +++ b/app/Http/Controllers/Api/StockEntryController.php @@ -7,13 +7,17 @@ use App\Models\OriginCountry; use App\Models\StockEntry; use App\Models\StockEntrySection; use App\Models\StockEntryStatus; +use App\Models\StockEntryStatusHistory; use App\Models\StockPosition; use App\Models\PhysicalItem; use App\Models\StockSection; use App\Models\Supplier; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use OwenIt\Auditing\Models\Audit; class StockEntryController extends Controller @@ -98,23 +102,6 @@ class StockEntryController extends Controller '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); @@ -138,14 +125,6 @@ class StockEntryController extends Controller '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', @@ -367,8 +346,9 @@ class StockEntryController extends Controller 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) + $physicalItems = PhysicalItem::with(['type', 'manufacturer']) + ->where('name', 'like', '%' . $request->input('item_name', '') . '%') + ->limit(100) ->get(); return response()->json([ @@ -405,4 +385,85 @@ class StockEntryController extends Controller 'statuses' => $entry, ]); } + + public function countStock(Request $request) + { + // 1) Validate input + try { + $payload = $request->validate([ + 'entryId' => 'required|integer|exists:stock_entries,id', + 'quantity' => 'required|integer|min:0', + ]); + } catch (ValidationException $e) { + // Return 422 + a JSON body with errors + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + try { + // 2) Fetch the StockEntry (will throw ModelNotFoundException if not exists) + $entry = StockEntry::findOrFail($payload['entryId']); + + // 3) If original_count is already set → 409 conflict + if ($entry->original_count !== 0) { + return response()->json([ + 'success' => false, + 'error' => 'already_counted', + 'message' => 'This stock entry has already been counted.', + ], 409); + } + + $quantity = $payload['quantity']; + // Determine which status to record + if ($entry->original_count_invoice === $quantity) { + StockEntryStatusHistory::create([ + 'stock_entries_id' => $entry->id, + 'stock_entries_status_id' => StockEntryStatus::NEW_GOODS_COUNTED, + ]); + } else { + $statusId = $entry->original_count_invoice > $quantity + ? StockEntryStatus::NEW_GOODS_MISSING + : StockEntryStatus::NEW_GOODS_SURPLUS; + + StockEntryStatusHistory::create([ + 'stock_entries_id' => $entry->id, + 'stock_entries_status_id' => $statusId, + 'status_note' => 'Count: ' . ($quantity - $entry->original_count_invoice) + ]); + } + + + $entry->update([ + 'original_count' => $quantity, + 'count' => $quantity, + ]); + + return response()->json([ + 'success' => true, + ]); // HTTP 200 + + } catch (ModelNotFoundException $e) { + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'Stock entry not found.', + ], 404); + + } catch (\Exception $e) { + // Log the exception for debugging + Log::error('countStock error', [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } } diff --git a/app/Http/Controllers/Api/StockLineController.php b/app/Http/Controllers/Api/StockLineController.php new file mode 100644 index 0000000..5001fd5 --- /dev/null +++ b/app/Http/Controllers/Api/StockLineController.php @@ -0,0 +1,42 @@ +validate([ + 'room_id' => 'required|exists:stock_room,room_id', + 'line_symbol' => 'required|string|max:50', + 'line_name' => 'required|string|max:100', + ]); + + $line = StockLine::create($data); + + return response()->json($line, 201); + } + + public function update(Request $request, StockLine $line) + { + $data = $request->validate([ + 'line_symbol' => 'sometimes|required|string|max:50', + 'line_name' => 'sometimes|required|string|max:100', + ]); + + $line->update($data); + + return response()->json($line); + } + + public function destroy(StockLine $line) + { + $line->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/StockPositionController.php b/app/Http/Controllers/Api/StockPositionController.php new file mode 100644 index 0000000..8b62f49 --- /dev/null +++ b/app/Http/Controllers/Api/StockPositionController.php @@ -0,0 +1,123 @@ + // new capacity for the position + * } + * + * - Validates that capacity ≥ 0. + * - Ensures the new capacity is not less than the sum of existing section capacities. + * - Returns JSON { success: true } on success, + * or { success: false, error: "", message: "
" } on failure. + */ + public function update(Request $request, $id) + { + // 1) Find the position + $position = StockPosition::find($id); + if (!$position) { + return response() + ->json(['success' => false, 'error' => 'not_found', 'message' => 'Position not found.'], 404); + } + + // 2) Validate incoming payload + $validator = Validator::make($request->all(), [ + 'capacity' => 'required|integer|min:0', + ]); + + if ($validator->fails()) { + return response() + ->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $validator->errors()->first(), + ], 422); + } + + $newCapacity = $request->input('capacity'); + + // 3) Check that new capacity ≥ sum of all existing (non-deleted) sections' capacities + // (we assume “sections” relationship returns the live StockSection models) + $currentSectionsSum = $position + ->sections() + ->sum('capacity'); + + if ($newCapacity < $currentSectionsSum) { + return response() + ->json([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => "New capacity ({$newCapacity}) is less than total of existing section capacities ({$currentSectionsSum}).", + ], 422); + } + + // 4) All good → update and save + $position->capacity = $newCapacity; + $position->save(); + + return response()->json(['success' => true], 200); + } + + + public function getPosition(Request $request) + { + // 1) Eager‐load the real relationships + $position = StockPosition::with([ + 'sections.entries.physicalItem', + 'shelf.rack.line.room', + ])->findOrFail(2); + + // 2) Compute the storage address string + $position->storage_address = $position->storageAddress(); + + // 3) Return the model itself (now including a top‐level "storage_address" field) + return response()->json($position); + } + + + public function store(Request $request) + { + $data = $request->validate([ + 'shelf_id' => 'required|exists:stock_shelf,shelf_id', + 'position_symbol' => 'required|string|max:50', + 'position_name' => 'required|string|max:100', + 'capacity' => 'required|integer|min:0', + ]); + + $pos = StockPosition::create($data); + + return response()->json($pos, 201); + } + + public function updateBasic(Request $request, StockPosition $pos) + { + $data = $request->validate([ + 'position_symbol' => 'sometimes|required|string|max:50', + 'position_name' => 'sometimes|required|string|max:100', + 'capacity' => 'sometimes|required|integer|min:0', + ]); + + $pos->update($data); + + return response()->json($pos); + } + + public function destroy(StockPosition $pos) + { + $pos->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/StockRackController.php b/app/Http/Controllers/Api/StockRackController.php new file mode 100644 index 0000000..eb71f7d --- /dev/null +++ b/app/Http/Controllers/Api/StockRackController.php @@ -0,0 +1,42 @@ +validate([ + 'line_id' => 'required|exists:stock_line,line_id', + 'rack_symbol' => 'required|string|max:50', + 'rack_name' => 'required|string|max:100', + ]); + + $rack = StockRack::create($data); + + return response()->json($rack, 201); + } + + public function update(Request $request, StockRack $rack) + { + $data = $request->validate([ + 'rack_symbol' => 'sometimes|required|string|max:50', + 'rack_name' => 'sometimes|required|string|max:100', + ]); + + $rack->update($data); + + return response()->json($rack); + } + + public function destroy(StockRack $rack) + { + $rack->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/StockRoomController.php b/app/Http/Controllers/Api/StockRoomController.php new file mode 100644 index 0000000..f2fef20 --- /dev/null +++ b/app/Http/Controllers/Api/StockRoomController.php @@ -0,0 +1,80 @@ +validate([ + 'room_symbol' => 'required|string', + 'room_name' => 'required|string', + 'lines' => 'required|integer|min:1', + 'racks_per_line' => 'required|integer|min:1', + 'shelves_per_rack' => 'required|integer|min:1', + 'positions_per_shelf' => 'required|integer|min:1', + ]); + + \DB::transaction(function() use($data) { + $room = StockRoom::create([ + 'room_symbol' => $data['room_symbol'], + 'room_name' => $data['room_name'], + ]); + + for ($i = 1; $i <= $data['lines']; $i++) { + $line = $room->lines()->create([ + 'line_symbol' => $i, + 'line_name' => 'Line ' . $i, + ]); + for ($j = 1; $j <= $data['racks_per_line']; $j++) { + $rack = $line->racks()->create([ + 'rack_symbol' => $j, + 'rack_name' => 'Rack ' . $j, + ]); + for ($k = 1; $k <= $data['shelves_per_rack']; $k++) { + $shelf = $rack->shelves()->create([ + 'shelf_symbol' => $k, + 'shelf_name' => 'Shelf ' . $k, + ]); + for ($p = 1; $p <= $data['positions_per_shelf']; $p++) { + $shelf->positions()->create([ + 'position_symbol' => $p, + 'position_name' => 'Position ' . $p, + 'capacity' => 0, + ]); + } + } + } + } + }); + + return response()->json(['message' => 'Room created'], 201); + } + + public function show(StockRoom $room) + { + $room->load('lines.racks.shelves.positions.sections'); + return response()->json($room); + } + + public function update(Request $request, StockRoom $room) + { + // You can use a single JSON payload with full tree edits, or + // issue individual calls per entity (lines, racks, etc.) + // For brevity, let’s assume front-end will call your existing modals + // to update positions or sections via your other APIs. + $room->update($request->only('room_symbol','room_name')); + return response()->json(['message'=>'Room updated']); + } + + public function destroy(StockRoom $room) + { + $room->delete(); + return response()->json(['message'=>'Room deleted']); + } +} diff --git a/app/Http/Controllers/Api/StockSectionController.php b/app/Http/Controllers/Api/StockSectionController.php new file mode 100644 index 0000000..8377c53 --- /dev/null +++ b/app/Http/Controllers/Api/StockSectionController.php @@ -0,0 +1,711 @@ +json([ + 'section' => StockSection::with(['position', 'entries.physicalItem'])->findOrFail($id) + ], 200); + } + catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'StockSection not found.', + ], 404); + } + + + } + + + public function setSection(Request $request) + { + // 1) Validate input + try { + $data = $request->validate([ + 'section_id' => 'required|integer|exists:stock_section,section_id', + 'entry_id' => 'required|integer|exists:stock_entries,id', + 'count_to_be_stored' => 'required|integer|min:1', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + $sectionId = $data['section_id']; + $entryId = $data['entry_id']; + $count = $data['count_to_be_stored']; + + try { + // 2) Locate section and entry (404 if not found) + $section = StockSection::findOrFail($sectionId); + $entry = StockEntry::findOrFail($entryId); + + // 3) If the section is already occupied → 409 Conflict + if ($section->occupied()) { + return response()->json([ + 'success' => false, + 'error' => 'section_occupied', + 'message' => 'That section is already occupied.', + ], 409); + } + + // 4) Check capacity: count_to_be_stored must be <= section’s capacity + if ($count > $section->capacity) { + return response()->json([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => 'Not enough capacity in this section.', + 'available_capacity' => $section->capacity, + ], 409); + } + + // 5) Create the pivot record and mark section as occupied + StockEntries2Section::create([ + 'entry_id' => $entry->id, + 'section_id' => $section->section_id, + 'count' => $count, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Entry assigned to section successfully.', + ], 200); + + } catch (ModelNotFoundException $e) { + // Either the section or the entry was not found + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'StockSection or StockEntry not found.', + ], 404); + + } catch (\Exception $e) { + // Log unexpected exceptions + Log::error('Error in setSection', [ + 'section_id' => $sectionId, + 'entry_id' => $entryId, + 'count' => $count, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } + + public function storeStock(Request $request) + { + // 1) Validate input + try { + $data = $request->validate([ + 'section_id' => 'required|integer|exists:stock_section,section_id', // new section + 'current_section' => 'required|integer|exists:stock_section,section_id', // old section + 'entry_id' => 'required|integer|exists:stock_entries,id', + 'count_to_be_stored' => 'required|integer|min:1', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + $newSectionId = $data['section_id']; + $currentSectionId = $data['current_section']; + $entryId = $data['entry_id']; + $moveCount = $data['count_to_be_stored']; + + try { + DB::transaction(function () use ( + $newSectionId, + $currentSectionId, + $entryId, + $moveCount + ) { + // 2) Load sections and entry + $currentSection = StockSection::findOrFail($currentSectionId); + $newSection = StockSection::findOrFail($newSectionId); + $entry = StockEntry::findOrFail($entryId); + + // 3) Check occupation: allow if occupied only by this same entry + $existingInNew = StockEntries2Section::where('section_id', $newSectionId) + ->where('entry_id', $entryId) + ->first(); + if ($newSection->occupied() && ! $existingInNew) { + abort(409, json_encode([ + 'success' => false, + 'error' => 'section_occupied', + 'message' => 'Target section is already occupied by another item.', + ])); + } + + // 4) Locate pivot in current section + $pivot = StockEntries2Section::where('section_id', $currentSectionId) + ->where('entry_id', $entryId) + ->firstOrFail(); + + // 5) Ensure there is enough count in current section + if ($pivot->count < $moveCount) { + abort(409, json_encode([ + 'success' => false, + 'error' => 'insufficient_count', + 'message' => 'Not enough items in the current section to move.', + 'available' => $pivot->count, + ])); + } + + // 6) Decrement or remove pivot in current section + $remaining = $pivot->count - $moveCount; + if ($remaining > 0) { + DB::table('stock_entries2section') + ->where('entry_id', $entryId) + ->where('section_id', $currentSectionId) + ->limit(1) + ->update(['count' => $remaining]); + } else { + DB::table('stock_entries2section') + ->where('entry_id', $entryId) + ->where('section_id', $currentSectionId) + ->limit(1) + ->delete(); + + // if no more entries in this section, mark it unoccupied + if (! DB::table('stock_entries2section')->where('section_id', $currentSectionId)->exists()) { + $currentSection->update(['occupied' => false]); + } + } + + // 7) Upsert into new section, merging counts if already present + // lockForUpdate to avoid race conditions + $existing = DB::table('stock_entries2section') + ->where('entry_id', $entryId) + ->where('section_id', $newSectionId) + ->lockForUpdate() + ->first(); + + $currentInNew = $existing->count ?? 0; + $totalAfter = $currentInNew + $moveCount; + + // 8) Ensure capacity in new section + if ($totalAfter > $newSection->capacity) { + abort(409, json_encode([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => 'Not enough capacity in target section.', + 'available_capacity' => $newSection->capacity - $currentInNew, + ])); + } + + if ($existing) { + DB::table('stock_entries2section') + ->where('entry_id', $entryId) + ->where('section_id', $newSectionId) + ->limit(1) + ->update(['count' => $totalAfter]); + } else { + StockEntries2Section::create([ + 'entry_id' => $entry->id, + 'section_id' => $newSectionId, + 'count' => $moveCount, + ]); + } + + // 9) Mark new section occupied + $newSection->update(['occupied' => true]); + }); + + // success response + return response()->json([ + 'success' => true, + 'message' => 'Moved stock successfully.', + ], 200); + + } catch (ModelNotFoundException $e) { + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'StockSection or StockEntry not found.', + ], 404); + + } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { + $payload = json_decode($e->getMessage(), true); + return response()->json($payload, $e->getStatusCode()); + + } catch (\Exception $e) { + Log::error('Error in storeStock', [ + 'current_section' => $currentSectionId, + 'new_section' => $newSectionId, + 'entry_id' => $entryId, + 'count' => $moveCount, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } + + public function setSectionForBatch(Request $request) + { + // 1) Validate input + try { + $data = $request->validate([ + 'position_id' => 'required|integer|exists:stock_position,position_id', + 'batch_id' => 'required|integer|exists:stock_batch,id', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + $positionId = $data['position_id']; + $batchId = $data['batch_id']; + + try { + // 2) Locate section and entry (404 if not found) + $position = StockPosition::findOrFail($positionId); + $batch = StockBatch::with(['stockEntries'])->findOrFail($batchId); + + $anyOccupied = $position->sections->contains(function($section) { + return $section->occupied(); + }); + // 3) If the section is already occupied → 409 Conflict + if ($anyOccupied) { + return response()->json([ + 'success' => false, + 'error' => 'position_occupied', + 'message' => 'That position is already occupied.', + ], 409); + } + + if (!$position->temporary) { + return response()->json([ + 'success' => false, + 'error' => 'section_not_tmp', + 'message' => 'That section is not suitable for entire batch temporary storage.', + ], 409); + } + + // Delete all existing sections for this position + StockSection::where('position_id', $position->position_id)->delete(); + + // Create new sections for each entry + $sectionCounter = 1; + foreach ($batch->stockEntries as $entry) { + // Create new section + $section = StockSection::create([ + 'position_id' => $position->position_id, + 'section_name' => (string)$sectionCounter, + 'section_symbol' => (string)$sectionCounter, + 'capacity' => $entry->count, + 'retrievable' => true + ]); + + // Create mapping + StockEntries2Section::create([ + 'entry_id' => $entry->id, + 'section_id' => $section->section_id, + 'count' => $entry->count + ]); + + $sectionCounter++; + } + + return response()->json([ + 'success' => true, + 'message' => 'Batch stored to position successfully.', + ], 200); + + } catch (ModelNotFoundException $e) { + // Either the section or the entry was not found + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'StockSection or StockEntry not found.', + ], 404); + + } catch (\Exception $e) { + // Log unexpected exceptions + Log::error('Error in setSectionForBatch', [ + 'position_id' => $positionId, + 'batch_id' => $batchId, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } + + public function movePosition(Request $request) + { + // 1) Validate input + try { + $data = $request->validate([ + 'section_id' => 'required|integer|exists:stock_section,section_id', + 'new_section_id' => 'required|integer|exists:stock_section,section_id', + 'entry_id' => 'required|integer|exists:stock_entries,id', + 'count' => 'required|integer|min:1', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + $sectionId = $data['section_id']; + $newSectionId = $data['new_section_id']; + $entryId = $data['entry_id']; + $count = $data['count']; + + try { + // 2) Locate section and entry (404 if not found) + $new_section = StockSection::findOrFail($newSectionId); + $entry = StockEntry::findOrFail($entryId); + + // 3) If the section is already occupied → 409 Conflict + if ($new_section->occupied()) { + return response()->json([ + 'success' => false, + 'error' => 'section_occupied', + 'message' => 'That section is already occupied.', + ], 409); + } + + // 4) Check capacity: count_to_be_stored must be <= section’s capacity + if ($count > $new_section->capacity) { + return response()->json([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => 'Not enough capacity in this section.', + 'available_capacity' => $new_section->capacity, + ], 409); + } + + // use query builder because of composite PK + DB::table('stock_entries2section') + ->where('entry_id', $entryId) + ->where('section_id', $sectionId) + ->limit(1) // ensure only one row is touched + ->update([ + 'section_id' => $new_section->section_id, + 'count' => $count, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Section changed successfully.', + ], 200); + + } catch (ModelNotFoundException $e) { + // Either the section or the entry was not found + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'StockSection or StockEntry not found.', + ], 404); + + } catch (\Exception $e) { + // Log unexpected exceptions + Log::error('Error in setSection', [ + 'section_id' => $sectionId, + 'new_section_id' => $newSectionId, + 'entry_id' => $entryId, + 'count' => $count, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } + + public function changeCount(Request $request) + { + // 1) Validate input + try { + $data = $request->validate([ + 'section_id' => 'required|integer|exists:stock_section,section_id', + 'entry_id' => 'required|integer|exists:stock_entries,id', + 'count' => 'required|integer|min:1', + 'new_count' => 'required|integer|min:1', + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $e->errors(), + ], 422); + } + + $sectionId = $data['section_id']; + $entryId = $data['entry_id']; + $count = $data['count']; + $new_count = $data['new_count']; + + try { + // 2) Locate section and entry (404 if not found) + $section = StockSection::findOrFail($sectionId); + $entry = StockEntry::findOrFail($entryId); + + + // Check capacity: count_to_be_stored must be <= section’s capacity + if ($new_count > $section->capacity) { + return response()->json([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => 'Not enough capacity in this section.', + 'available_capacity' => $section->capacity, + ], 409); + } + + // use query builder because of composite PK + DB::table('stock_entries2section') + ->where('entry_id', $entryId) + ->where('section_id', $sectionId) + ->limit(1) // ensure only one row is touched + ->update([ + 'count' => $new_count, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Section changed successfully.', + ], 200); + + } catch (ModelNotFoundException $e) { + // Either the section or the entry was not found + return response()->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'StockSection or StockEntry not found.', + ], 404); + + } catch (\Exception $e) { + // Log unexpected exceptions + Log::error('Error in setSection', [ + 'section_id' => $sectionId, + 'new_section_id' => $newSectionId, + 'entry_id' => $entryId, + 'count' => $count, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'success' => false, + 'error' => 'server_error', + 'message' => 'An unexpected error occurred.', + ], 500); + } + } + + public function store(Request $request) + { + // 1) Validate incoming payload + $validator = Validator::make($request->all(), [ + 'position_id' => 'required|integer|exists:stock_position,position_id', + 'section_symbol' => 'required|string|max:5', + 'section_name' => 'required|string|max:32', + 'capacity' => 'required|integer|min:0', + 'retrievable' => 'required|boolean', + ]); + + if ($validator->fails()) { + return response() + ->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $validator->errors()->first(), + ], 422); + } + + // 2) Find the parent position + $position = StockPosition::find($request->input('position_id')); + if (!$position) { + return response() + ->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'Position not found.', + ], 404); + } + + $newCapacity = $request->input('capacity'); + + // 3) Check that (existing sections sum) + newCapacity ≤ position.capacity + $existingSum = $position + ->sections() + ->sum('capacity'); + + if ($existingSum + $newCapacity > $position->capacity) { + return response() + ->json([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => "Creating this section would push total capacity ({$existingSum} + {$newCapacity}) over position capacity ({$position->capacity}).", + ], 422); + } + + // 4) Create the new section + $section = new StockSection(); + $section->position_id = $position->position_id; + $section->section_symbol = $request->input('section_symbol'); + $section->section_name = $request->input('section_name'); + $section->capacity = $newCapacity; + $section->retrievable = $request->input('retrievable'); + $section->save(); + + return response() + ->json([ + 'success' => true, + 'section_id' => $section->section_id, + ], 200); + } + + /** + * Update an existing StockSection. + * + * Endpoint: PUT /api/stockSections/{id} + * + * Request payload: + * { + * "section_name": , + * "capacity": , + * "retrievable": + * } + * + * Responses: + * • 200 → { success: true } + * • 404 → { success: false, error: "not_found", message: "Section not found." } + * • 422 → { success: false, error: "validation_failed" / "insufficient_capacity", message: "..." } + */ + public function update(Request $request, $id) + { + // 1) Find the section + $section = StockSection::find($id); + if (!$section) { + return response() + ->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'Section not found.', + ], 404); + } + + // 2) Validate incoming payload + $validator = Validator::make($request->all(), [ + 'section_name' => 'required|string|max:255', + 'capacity' => 'required|integer|min:0', + 'retrievable' => 'required|boolean', + ]); + + if ($validator->fails()) { + return response() + ->json([ + 'success' => false, + 'error' => 'validation_failed', + 'message' => $validator->errors()->first(), + ], 422); + } + + $updatedCapacity = $request->input('capacity'); + $position = $section->position; // via relation + + // 3) Check that sum of *other* sections + updatedCapacity ≤ position.capacity + $otherSectionsSum = $position + ->sections() + ->where('section_id', '!=', $id) + ->sum('capacity'); + + if ($otherSectionsSum + $updatedCapacity > $position->capacity) { + return response() + ->json([ + 'success' => false, + 'error' => 'insufficient_capacity', + 'message' => "Updating to capacity {$updatedCapacity} would push total of other sections ({$otherSectionsSum}) over position capacity ({$position->capacity}).", + ], 422); + } + + // 4) Apply changes + $section->section_name = $request->input('section_name'); + $section->capacity = $updatedCapacity; + $section->retrievable = $request->input('retrievable'); + $section->save(); + + return response()->json(['success' => true], 200); + } + + /** + * Delete a StockSection by its ID. + * + * Endpoint: DELETE /api/stockSections/{id} + * + * Responses: + * • 200 → { success: true } + * • 404 → { success: false, error: "not_found", message: "Section not found." } + */ + public function destroy($id) + { + $section = StockSection::find($id); + if (!$section) { + return response() + ->json([ + 'success' => false, + 'error' => 'not_found', + 'message' => 'Section not found.', + ], 404); + } + + $section->delete(); + + return response()->json(['success' => true], 200); + } + +} diff --git a/app/Http/Controllers/Api/StockShelfController.php b/app/Http/Controllers/Api/StockShelfController.php new file mode 100644 index 0000000..7706939 --- /dev/null +++ b/app/Http/Controllers/Api/StockShelfController.php @@ -0,0 +1,42 @@ +validate([ + 'rack_id' => 'required|exists:stock_rack,rack_id', + 'shelf_symbol' => 'required|string|max:50', + 'shelf_name' => 'required|string|max:100', + ]); + + $shelf = StockShelf::create($data); + + return response()->json($shelf, 201); + } + + public function update(Request $request, StockShelf $shelf) + { + $data = $request->validate([ + 'shelf_symbol' => 'sometimes|required|string|max:50', + 'shelf_name' => 'sometimes|required|string|max:100', + ]); + + $shelf->update($data); + + return response()->json($shelf); + } + + public function destroy(StockShelf $shelf) + { + $shelf->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/StorageController.php b/app/Http/Controllers/Api/StorageController.php new file mode 100644 index 0000000..fa5002e --- /dev/null +++ b/app/Http/Controllers/Api/StorageController.php @@ -0,0 +1,102 @@ +validate([ + 'room_symbol' => 'required|string|max:50', + 'room_name' => 'required|string|max:100', + 'num_lines' => 'nullable|integer|min:1', + 'racks_per_line' => 'nullable|integer|min:1', + 'shelves_per_rack' => 'nullable|integer|min:1', + 'positions_per_shelf' => 'nullable|integer|min:1', + 'sections_per_position'=> 'nullable|integer|min:1', + ]); + + // Apply defaults if any count is missing or invalid: + $numLines = $data['num_lines'] ?? 1; + $racksPerLine = $data['racks_per_line'] ?? 1; + $shelvesPerRack= $data['shelves_per_rack'] ?? 1; + $positionsPerShelf = $data['positions_per_shelf'] ?? 1; + $sectionsPerPosition= $data['sections_per_position'] ?? 1; + + // 1) Create the Room + $room = StockRoom::create([ + 'room_symbol' => $data['room_symbol'], + 'room_name' => $data['room_name'], + ]); + + // 2) Create N lines + for ($i = 1; $i <= $numLines; $i++) { + $line = $room->lines()->create([ + 'line_symbol' => $room->room_symbol . '_L' . $i, + 'line_name' => $room->room_name . ' Line ' . $i, + ]); + + // 3) For each line, create M racks + for ($j = 1; $j <= $racksPerLine; $j++) { + $rack = $line->racks()->create([ + 'rack_symbol' => $line->line_symbol . '_R' . $j, + 'rack_name' => $line->line_name . ' Rack ' . $j, + ]); + + // 4) For each rack, create P shelves + for ($k = 1; $k <= $shelvesPerRack; $k++) { + $shelf = $rack->shelves()->create([ + 'shelf_symbol' => $rack->rack_symbol . '_S' . $k, + 'shelf_name' => $rack->rack_name . ' Shelf ' . $k, + ]); + + // 5) For each shelf, create Q positions + for ($m = 1; $m <= $positionsPerShelf; $m++) { + $position = $shelf->positions()->create([ + 'position_symbol' => $shelf->shelf_symbol . '_P' . $m, + 'position_name' => $shelf->shelf_name . ' Position ' . $m, + ]); + + // 6) For each position, create R sections + for ($n = 1; $n <= $sectionsPerPosition; $n++) { + $position->sections()->create([ + 'section_symbol' => $position->position_symbol . '_SEC' . $n, + 'section_name' => $position->position_name . ' Section ' . $n, + 'capacity' => 0, + 'retrievable' => true, + ]); + } + } + } + } + } + + return response()->json([ + 'message' => 'Storage layout created successfully.', + 'room_id' => $room->room_id, + ], 201); + } +} diff --git a/app/Models/PhysicalItem.php b/app/Models/PhysicalItem.php index 39b2427..dcdb100 100644 --- a/app/Models/PhysicalItem.php +++ b/app/Models/PhysicalItem.php @@ -4,6 +4,7 @@ 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 PhysicalItem extends Model @@ -47,4 +48,9 @@ class PhysicalItem extends Model return $this->hasOne(Manufacturer::class, 'id', 'manufacturer_id'); } + public function type(): BelongsTo + { + return $this->belongsTo(PhysicalItemType::class, 'physical_item_type_id'); + } + } diff --git a/app/Models/PhysicalItemType.php b/app/Models/PhysicalItemType.php new file mode 100644 index 0000000..566d4be --- /dev/null +++ b/app/Models/PhysicalItemType.php @@ -0,0 +1,70 @@ +hasMany(PhysicalItem::class, 'physical_item_type_id'); + } + + /** + * The title template associated with this item type. + */ + public function titleTemplate(): BelongsTo + { + return $this->belongsTo(Template::class, 'title_template_id'); + } + + /** + * The description template associated with this item type. + */ + public function descriptionTemplate(): BelongsTo + { + return $this->belongsTo(Template::class, 'description_template_id'); + } + + /** + * The user who created this record. + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * The user who last updated this record. + */ + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/StockEntry.php b/app/Models/StockEntry.php index b9a21df..613f4ef 100644 --- a/app/Models/StockEntry.php +++ b/app/Models/StockEntry.php @@ -27,6 +27,7 @@ class StockEntry extends Model implements AuditableContract 'supplier_id', 'count', 'original_count', + 'original_count_invoice', 'price', 'bought', 'description', @@ -38,6 +39,10 @@ class StockEntry extends Model implements AuditableContract 'updated_by', ]; + protected $appends = [ + 'count_stocked', + ]; + /** * The attributes that should be cast. * @@ -48,6 +53,14 @@ class StockEntry extends Model implements AuditableContract 'on_the_way' => 'boolean', ]; + + public function getCountStockedAttribute(): int + { + // calls your existing method, which will + // loadMissing the relations if needed + return $this->countStockedItems(); + } + /** * Get the physical item associated with the stock entry. */ @@ -141,4 +154,12 @@ class StockEntry extends Model implements AuditableContract }) ->toArray(); } + + + public function countStockedItems(): int + { + return (int) $this + ->sections() + ->sum('stock_entries2section.count'); + } } diff --git a/app/Models/StockEntryStatus.php b/app/Models/StockEntryStatus.php index abf0410..d18082f 100644 --- a/app/Models/StockEntryStatus.php +++ b/app/Models/StockEntryStatus.php @@ -28,7 +28,6 @@ class StockEntryStatus extends Model 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; diff --git a/app/Models/StockPosition.php b/app/Models/StockPosition.php index 6bf0dca..15eea3b 100644 --- a/app/Models/StockPosition.php +++ b/app/Models/StockPosition.php @@ -13,15 +13,52 @@ class StockPosition extends Model 'position_symbol', 'position_name', 'shelf_id', + 'capacity', + 'temporary' ]; + protected $appends = [ + 'storage_address', + ]; + + public function getStorageAddressAttribute(): string + { + // calls your existing method, which will + // loadMissing the relations if needed + return $this->storageAddress(); + } + public function shelf() { - return $this->belongsTo(StockShelf::class, 'shelf_id', 'shelf_id'); + return $this->belongsTo(StockShelf::class, 'shelf_id'); } public function sections() { return $this->hasMany(StockSection::class, 'position_id', 'position_id'); } + + + + public function storageAddress(): string + { + // eager-load the whole hierarchy if not already + $this->loadMissing('shelf.rack.line.room'); + + + $shelf = $this->shelf; + $rack = $shelf->rack; + $line = $rack->line; + $room = $line->room; + + return sprintf( + '%s-%s-%s-%s-%s', + $room->room_symbol, + $line->line_symbol, + $rack->rack_symbol, + $shelf->shelf_symbol, + $this->position_symbol + ); + + } } diff --git a/app/Models/StockSection.php b/app/Models/StockSection.php index 52164be..375a148 100644 --- a/app/Models/StockSection.php +++ b/app/Models/StockSection.php @@ -17,6 +17,10 @@ class StockSection extends Model 'retrievable' ]; + protected $appends = [ + 'storage_address', + ]; + public function position() { return $this->belongsTo(StockPosition::class, 'position_id', 'position_id'); @@ -40,4 +44,36 @@ class StockSection extends Model return $this->entries()->exists(); } + + public function getStorageAddressAttribute(): string + { + // calls your existing method, which will + // loadMissing the relations if needed + return $this->storageAddress(); + } + + public function storageAddress(): string + { + // eager-load the whole hierarchy if not already + $this->loadMissing('position.shelf.rack.line.room'); + + + $position = $this->position; + $shelf = $position->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, + $position->position_symbol, + $this->section_symbol + ); + + } + } diff --git a/composer.json b/composer.json index 7828a38..4fecd34 100644 --- a/composer.json +++ b/composer.json @@ -11,12 +11,14 @@ "require": { "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", + "intervention/image": "^3.11", "laravel/framework": "^12.0", "laravel/jetstream": "^5.3", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "owen-it/laravel-auditing": "^14.0", - "tightenco/ziggy": "^2.0" + "tightenco/ziggy": "^2.0", + "z38/metzli": "^1.1" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index ef4cd31..14ced01 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": "87a6c86e9f85c22d4b6e67d73bb8823d", + "content-hash": "2cc047a2ea18870d90d8b51342c3003a", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1226,6 +1226,150 @@ }, "time": "2025-04-10T15:08:36+00:00" }, + { + "name": "intervention/gif", + "version": "4.2.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/5999eac6a39aa760fb803bc809e8909ee67b451a", + "reference": "5999eac6a39aa760fb803bc809e8909ee67b451a", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/4.2.2" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-03-29T07:46:21+00:00" + }, + { + "name": "intervention/image", + "version": "3.11.3", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/d0f097b8a3fa8fb758efc9440b513aa3833cda17", + "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^4.2", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "slevomat/coding-standard": "~8.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "suggest": { + "ext-exif": "Recommended to be able to read EXIF data properly." + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP image manipulation", + "homepage": "https://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.11.3" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + }, + { + "url": "https://ko-fi.com/interventionphp", + "type": "ko_fi" + } + ], + "time": "2025-05-22T17:26:23+00:00" + }, { "name": "laravel/fortify", "version": "v1.25.4", @@ -6540,6 +6684,58 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "z38/metzli", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/z38/metzli.git", + "reference": "ff8e22c492d02ba33dce8c039ffc6733cb877f63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/z38/metzli/zipball/ff8e22c492d02ba33dce8c039ffc6733cb877f63", + "reference": "ff8e22c492d02ba33dce8c039ffc6733cb877f63", + "shasum": "" + }, + "require": { + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "~5 || ~7", + "setasign/fpdf": "^1.8" + }, + "type": "library", + "autoload": { + "psr-0": { + "Metzli": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "z38", + "homepage": "https://github.com/z38", + "role": "Developer" + } + ], + "description": "PHP library to generate Aztec barcodes", + "homepage": "https://github.com/z38/metzli", + "keywords": [ + "2d-barcode", + "aztec", + "barcode", + "library" + ], + "support": { + "issues": "https://github.com/z38/metzli/issues", + "source": "https://github.com/z38/metzli/tree/master" + }, + "time": "2018-05-14T08:34:22+00:00" } ], "packages-dev": [ diff --git a/config/sanctum.php b/config/sanctum.php index 764a82f..5561b61 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -17,7 +17,7 @@ return [ 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1, localhost:8004', Sanctum::currentApplicationUrlWithPort() ))), @@ -79,5 +79,6 @@ return [ 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, ], + 'prefix' => 'api' ]; diff --git a/init_config_db.sql b/init_config_db.sql index 96733c4..6cee71d 100644 --- a/init_config_db.sql +++ b/init_config_db.sql @@ -56,6 +56,8 @@ CREATE TABLE stock_position ( position_symbol VARCHAR(5) NOT NULL, position_name VARCHAR(32) DEFAULT NULL, shelf_id INT NOT NULL, + capacity int default 0, + temporary bool default false, created_at DATETIME NOT NULL DEFAULT NOW(), updated_at DATETIME NULL ON UPDATE NOW(), FOREIGN KEY (shelf_id) REFERENCES stock_shelf(shelf_id) @@ -91,6 +93,7 @@ create table stock_batch supplier_id int default null, tracking_number varchar(256) default null, arrival_date DATETIME DEFAULT null, + default_batch bool default false, created_at DATETIME DEFAULT now(), updated_at DATETIME DEFAULT NULL ON UPDATE now(), FOREIGN KEY (user_id) REFERENCES users (id) @@ -103,6 +106,7 @@ create table stock_entries supplier_id int unsigned not null, count int default 0 not null, #needitovatelny ve formulari / jen odpis, inventura original_count int default 0 not null, + original_count_invoice int default 0 not null, price double null, bought date default null, description text null, @@ -196,7 +200,7 @@ create table stock_entries_status_history 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 (stock_entries_id) REFERENCES stock_entries (id) on delete cascade, FOREIGN KEY (section_id) REFERENCES stock_section (section_id), FOREIGN KEY (stock_entries_status_id) REFERENCES stock_entries_status (id) ); diff --git a/package-lock.json b/package-lock.json index 80508ba..1f4bbe9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@headlessui/react": "^2.2.0", + "@heroicons/react": "^2.2.0", "@inertiajs/react": "^2.0.0", "@inertiajs/server": "^0.1.0", + "@material-tailwind/react": "^2.1.10", "@mui/icons-material": "^7.1.0", "@mui/material": "^7.1.0", "@mui/x-date-pickers": "^8.3.0", @@ -1056,6 +1058,15 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@inertiajs/core": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.9.tgz", @@ -1171,6 +1182,159 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@material-tailwind/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@material-tailwind/react/-/react-2.1.10.tgz", + "integrity": "sha512-xGU/mLDKDBp/qZ8Dp2XR7fKcTpDuFeZEBqoL9Bk/29kakKxNxjUGYSRHEFLsyOFf4VIhU6WGHdIS7tOA3QGJHA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "0.19.0", + "classnames": "2.3.2", + "deepmerge": "4.2.2", + "framer-motion": "6.5.1", + "material-ripple-effects": "2.0.1", + "prop-types": "15.8.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "tailwind-merge": "1.8.1" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/@material-tailwind/react/node_modules/@floating-ui/react": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.0.tgz", + "integrity": "sha512-fgYvN4ksCi5OvmPXkyOT8o5a8PSKHMzPHt+9mR6KYWdF16IAjWRLZPAAziI2sznaWT23drRFrYw64wdvYqqaQw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^1.2.2", + "aria-hidden": "^1.1.3", + "tabbable": "^6.0.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@material-tailwind/react/node_modules/@floating-ui/react-dom": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz", + "integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.2.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@material-tailwind/react/node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==", + "license": "MIT" + }, + "node_modules/@material-tailwind/react/node_modules/deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@material-tailwind/react/node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@material-tailwind/react/node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@motionone/animation": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz", + "integrity": "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==", + "license": "MIT", + "dependencies": { + "@motionone/easing": "^10.18.0", + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/dom": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.12.0.tgz", + "integrity": "sha512-UdPTtLMAktHiqV0atOczNYyDd/d8Cf5fFsd1tua03PqTwwCe/6lwhLSQ8a7TbnQ5SN0gm44N1slBfj+ORIhrqw==", + "license": "MIT", + "dependencies": { + "@motionone/animation": "^10.12.0", + "@motionone/generators": "^10.12.0", + "@motionone/types": "^10.12.0", + "@motionone/utils": "^10.12.0", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/easing": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.18.0.tgz", + "integrity": "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==", + "license": "MIT", + "dependencies": { + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/generators": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.18.0.tgz", + "integrity": "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==", + "license": "MIT", + "dependencies": { + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/types": { + "version": "10.17.1", + "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.17.1.tgz", + "integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==", + "license": "MIT" + }, + "node_modules/@motionone/utils": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.18.0.tgz", + "integrity": "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==", + "license": "MIT", + "dependencies": { + "@motionone/types": "^10.17.1", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.0.tgz", @@ -2759,6 +2923,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3431,6 +3607,53 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", + "integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", + "license": "MIT", + "dependencies": { + "@motionone/dom": "10.12.0", + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "popmotion": "11.0.3", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0 || ^18.0.0", + "react-dom": ">=16.8 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/framer-motion/node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", + "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/framer-motion/node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", + "license": "MIT", + "optional": true + }, + "node_modules/framesync": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-6.0.1.tgz", + "integrity": "sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3597,6 +3820,12 @@ "node": ">= 0.4" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", + "license": "MIT" + }, "node_modules/highlight-words": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz", @@ -4119,6 +4348,12 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/material-ripple-effects": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/material-ripple-effects/-/material-ripple-effects-2.0.1.tgz", + "integrity": "sha512-hHlUkZAuXbP94lu02VgrPidbZ3hBtgXBtjlwR8APNqOIgDZMV8MCIcsclL8FmGJQHvnORyvoQgC965vPsiyXLQ==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4369,6 +4604,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/popmotion": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-11.0.3.tgz", + "integrity": "sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA==", + "license": "MIT", + "dependencies": { + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -4792,6 +5039,16 @@ "node": ">=8" } }, + "node_modules/style-value-types": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.0.0.tgz", + "integrity": "sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA==", + "license": "MIT", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -4832,6 +5089,12 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.8.1.tgz", + "integrity": "sha512-+fflfPxvHFr81hTJpQ3MIwtqgvefHZFUHFiIHpVIRXvG/nX9+gu2P7JNlFu2bfDMJ+uHhi/pUgzaYacMoXv+Ww==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.6.tgz", diff --git a/package.json b/package.json index 11f7098..b47c7f4 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,10 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@headlessui/react": "^2.2.0", + "@heroicons/react": "^2.2.0", "@inertiajs/react": "^2.0.0", "@inertiajs/server": "^0.1.0", + "@material-tailwind/react": "^2.1.10", "@mui/icons-material": "^7.1.0", "@mui/material": "^7.1.0", "@mui/x-date-pickers": "^8.3.0", diff --git a/resources/css/app.css b/resources/css/app.css index ac5be57..5d86504 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2,47 +2,12 @@ @plugin '@tailwindcss/forms'; @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; +@plugin "daisyui" { + themes: light --default, dracula --prefersdark; } - @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/jetstream/**/*.blade.php'; diff --git a/resources/js/Components/BatchInfoWindow.tsx b/resources/js/Components/BatchInfoWindow.tsx new file mode 100644 index 0000000..8662d63 --- /dev/null +++ b/resources/js/Components/BatchInfoWindow.tsx @@ -0,0 +1,209 @@ +// components/BatchInfoWindow.tsx + +import React, { useEffect, useMemo, useState } from 'react' +import { + MaterialReactTable, + type MRT_ColumnDef, + type MRT_PaginationState, + type MRT_SortingState, +} from 'material-react-table' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faQrcode, faListOl } from '@fortawesome/free-solid-svg-icons' +import { NextRouter } from 'next/router' +import { StockBatch, StockEntry, Supplier } from '@/types' +import axios from 'axios' +import { toast } from 'react-hot-toast' +import { size } from 'lodash' + +import {downloadBatchBarcode} from "@/functions/functions" + +interface Status { + id: number + name: string +} + +interface BatchInfoWindowProps { + selectedBatch: StockBatch | null + formatDate?: (value: string, withTime?: boolean) => string + router: NextRouter + route: (name: string, params: Record) => string + + /** We now allow `entries` to be null, but internally we coalesce to [] */ + entries: StockEntry[] | null + + /** Any other props you had (for pagination / sorting, etc.) */ + entryColumns?: MRT_ColumnDef[] + entriesCount?: number + entriesLoading?: boolean + entriesPagination?: MRT_PaginationState + entriesSorting?: MRT_SortingState + entriesFilter?: string + setEntriesPagination?: (updater: MRT_PaginationState) => void + setEntriesSorting?: (sorter: MRT_SortingState) => void + setEntriesFilter?: (filter: string) => void + + openEntry?: (entry: StockEntry) => void + recountEnabled?: boolean +} + +const defaultFormatter = (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 statusIds = [2, 6, 7, 8] + +export default function BatchInfoWindow({ + selectedBatch, + formatDate = defaultFormatter, + router, + route, + openEntry, + recountEnabled + }: BatchInfoWindowProps) { + const [statuses, setStatuses] = useState([]) + /** Always keep an array here (never null). */ + + /** Fetch statuses once */ + useEffect(() => { + console.log(selectedBatch); + axios + .get('/api/stockStatusList') + .then((res) => setStatuses(res.data.statuses)) + .catch(() => toast.error('Failed to load status list')) + }, []) + + /** Example helper to calculate ratios */ + function calculateStatusRatio(entries: StockEntry[], statusId: number) { + const total = entries.length + const count = entries.filter((e) => + e.status_history?.some((h: any) => h.stock_entries_status_id === statusId) + ).length + const ratio = total > 0 ? count / total : 0 + return { count, total, ratio } + } + + const entryColumnsMemo = useMemo[]>( + () => [ + { accessorKey: 'id', header: 'ID', size: 80 }, + { accessorKey: 'physical_item.name', header: 'Physical Item', size: 200 }, + { accessorKey: 'supplier.name', header: 'Supplier', size: 150 }, + { + accessorKey: 'price', + header: 'Price', + size: 100, + Cell: ({ cell }) => cell.getValue()?.toFixed(2) || '-', + }, + // …add the rest of your columns here… + ], + [] + ) + + const hasNonCountedStatus = selectedBatch?.stock_entries?.some((stockEntry) => { + const nullSectionStatuses = + stockEntry.status_history?.filter((h: any) => h.section_id === null) ?? [] + if (nullSectionStatuses.length === 0) return true + const latest = nullSectionStatuses.reduce((prev, curr) => + new Date(prev.created_at) > new Date(curr.created_at) ? prev : curr + ) + return latest.stock_entries_status_id !== 2 + }) + + return ( +
+ {/* Left: Batch Details & Ratios */} +
+
+

Batch Details

+ + {selectedBatch ? ( +
    +
  • + ID: {selectedBatch.id} +
  • +
  • + Supplier: {selectedBatch.supplier.name} +
  • +
  • + Tracking #: {selectedBatch.tracking_number} +
  • +
  • + Arrival:{' '} + {formatDate(selectedBatch.arrival_date, false)} +
  • +
+ ) : ( +

No batch selected.

+ )} + + {selectedBatch && ( +
+

Status Ratios

+ {statusIds.map((id) => { + const { count, total, ratio } = calculateStatusRatio( + selectedBatch.stock_entries, + id + ) + const statusData = statuses.find((s) => s.id === id) + return ( +

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

+ ) + })} +
+ )} +
+ +
+
+ +
+ + {hasNonCountedStatus && selectedBatch && recountEnabled && ( +
+ +
+ )} +
+
+ + {/* Right: Stock Entries Table (never pass null!) */} +
+

Stock Entries

+
+ ({ + onClick: () => openEntry?.(row.original), + style: { cursor: 'pointer' }, + })} + /> +
+
+
+ ) +} diff --git a/resources/js/Components/ModalPDA.tsx b/resources/js/Components/ModalPDA.tsx index a726ab9..1e319fb 100644 --- a/resources/js/Components/ModalPDA.tsx +++ b/resources/js/Components/ModalPDA.tsx @@ -12,13 +12,14 @@ const ModalPDA: React.FC = ({ isOpen, onClose, children }) => { return (
-
+
+ {children}
diff --git a/resources/js/Components/ShipmentItemsAccordion.tsx b/resources/js/Components/ShipmentItemsAccordion.tsx new file mode 100644 index 0000000..6ec406a --- /dev/null +++ b/resources/js/Components/ShipmentItemsAccordion.tsx @@ -0,0 +1,292 @@ +import React, { useState, useRef } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faBoxesStacked, + faCheckCircle, + faCheckToSlot, + faClipboardQuestion, + faShuffle, +} from "@fortawesome/free-solid-svg-icons"; +import { ShipmentRequest } from "@/interfaces/interfaces"; + +interface ShipmentItemsAccordionProps { + selectedShipment: ShipmentRequest; + buttonStates: { [itemId: number]: any }; + setButtonStates: React.Dispatch>; + parentModalHandle: () => void; +} + +export default function ShipmentItemsAccordion({ + selectedShipment, + buttonStates, + setButtonStates, + parentModalHandle, + }: ShipmentItemsAccordionProps) { + // Track which accordion item (by id) is open. Default to first item. + const [openAccordion, setOpenAccordion] = useState( + selectedShipment.items[0]?.id || null + ); + // Track completed items by their id. + const [completedItems, setCompletedItems] = useState<{ [id: number]: boolean }>({}); + // Ref to store a hold timer for touch devices. + const holdTimerRef = useRef(null); + + // Modal state + const [modalPositionOpen, setModalPositionOpen] = useState(false); + const [currentItem, setCurrentItem] = useState(null); + const [fromPosition, setFromPosition] = useState(""); + const [toPosition, setToPosition] = useState(""); + // modalMode: "full" or "select-only" + const [modalMode, setModalMode] = useState<"full" | "select-only">("full"); + // modalKey: "neniNaPozici" | "doskladnit" | "zmenitPozici" + const [modalKey, setModalKey] = useState(null); + + // Helper to get or initialize the button state for an item + const getItemState = (itemId: number) => + buttonStates[itemId] || { + neniNaPozici: { value: false, details: [] }, + doskladnit: { value: false, details: [] }, + zmenitPozici: { value: false, details: [] }, + }; + + // Generic update function for button state for a given item and key. + const updateButtonState = (itemId: number, key: string, details: any[] = []) => { + setButtonStates((prev) => { + const currentState = getItemState(itemId); + const currentButtonState = currentState[key] || { value: false, details: [] }; + const toggledValue = !currentButtonState.value; + return { + ...prev, + [itemId]: { + ...currentState, + [key]: { value: toggledValue, details: toggledValue ? details : [] }, + }, + }; + }); + }; + + // Open the modal with appropriate mode and key + const handleOpenModal = ( + item: any, + key: "neniNaPozici" | "doskladnit" | "zmenitPozici", + mode: "full" | "select-only" + ) => { + setCurrentItem(item); + setModalKey(key); + setModalMode(mode); + setModalPositionOpen(true); + // Optionally notify parent modal + // parentModalHandle(); + }; + + // Confirm handler for the modal + const handleConfirmModal = () => { + if (currentItem && modalKey) { + if (modalMode === "full") { + updateButtonState(currentItem.id, modalKey, [ + { from: fromPosition, to: toPosition }, + ]); + } else { + // select-only: only pass the "from" detail + updateButtonState(currentItem.id, modalKey, [{ position: fromPosition }]); + } + } + // Reset modal state + setModalPositionOpen(false); + setCurrentItem(null); + setFromPosition(""); + setToPosition(""); + setModalMode("full"); + setModalKey(null); + // Optionally notify parent modal + // parentModalHandle(); + }; + + // Toggle the open state of an accordion item. + const handleToggle = (id: number) => { + if (openAccordion === id) { + setOpenAccordion(null); + } else { + setOpenAccordion(id); + } + }; + + // Mark an item as completed. + const handleComplete = (id: number) => { + setCompletedItems((prev) => ({ ...prev, [id]: true })); + if (openAccordion === id) { + setOpenAccordion(null); + } + }; + + // Touch event handlers for long press. + const onTouchStartHandler = (id: number) => { + holdTimerRef.current = window.setTimeout(() => { + handleComplete(id); + }, 1000); + }; + + const onTouchEndHandler = () => { + if (holdTimerRef.current) { + clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + }; + + return ( +
+ {selectedShipment.items.map((item) => { + const itemState = getItemState(item.id); + const isOpen = openAccordion === item.id; + const isCompleted = completedItems[item.id]; + // If an item is not open, it appears "greyed out" + const headerOpacity = isOpen ? "opacity-100" : "opacity-50"; + const buttonFunctionality = isOpen ? "" : "pointer-events-none"; + + return ( +
+
+ handleToggle(item.id)} + className="peer" + /> +
handleComplete(item.id)} + onTouchStart={() => onTouchStartHandler(item.id)} + onTouchEnd={onTouchEndHandler} + > +
+

+ {item.quantity}× {item.name} +

+

{item.item_note}

+
+ {item.model_number} + + {(item.price / item.quantity).toFixed(2)} {selectedShipment.currency} + +
+
+ {isCompleted && ( + + )} +
+
+
+ {Object.entries(item.stockData).flatMap(([stockName, stockArray]) => + stockArray.map((stock, idx) => ( +
+

{stockName}

+

Location: {stock.location}

+

Count: {stock.count} ks

+
+ )) + )} +
+
+
+ +
+ + + + +
+
+ ); + })} + + {/* Modal for changing position */} +
+
+

Change Position

+
+
+ + +
+ {modalMode === "full" && ( +
+ + setToPosition(e.target.value)} + placeholder="Enter new position" + /> +
+ )} +
+
+ + +
+
+
+
+ ); +} diff --git a/resources/js/Components/WarehouseExpediceDialog.tsx b/resources/js/Components/WarehouseExpediceDialog.tsx new file mode 100644 index 0000000..86cdf26 --- /dev/null +++ b/resources/js/Components/WarehouseExpediceDialog.tsx @@ -0,0 +1,265 @@ +import React, { useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCircleInfo, faCubesStacked, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { ShipmentRequest } from "@/interfaces/interfaces"; +import ShipmentItemsAccordion from "@/Components/ShipmentItemsAccordion"; +import axios from "axios"; + +interface WarehouseExpediceDialogProps { + selectedShipment: ShipmentRequest; + isDialogOpen: boolean; + closeDialog: () => void; + handleDialogProcess: () => void; + dialogToggleClose: () => void; + selectedType: "parcels" | "processed" | null; + buttonStates: { [itemId: number]: any }; + setButtonStates: React.Dispatch>; +} + +export default function WarehouseExpediceDialog({ + selectedShipment, + isDialogOpen, + closeDialog, + handleDialogProcess, + dialogToggleClose, + selectedType, + buttonStates, + setButtonStates, + }: WarehouseExpediceDialogProps) { + // Track which tab is active: "stock" or "details" + const [activeTab, setActiveTab] = useState<"stock" | "details">("stock"); + const [imageArray, setImageArray] = useState>({}); + + const fetchItemImages = () => { + axios + .get("/api/expediceListWMS/getImage", { + params: { selectedShipmentID: selectedShipment.id }, + }) + .then((response) => { + setImageArray(response.data.imageArray || {}); + }) + .catch((error) => { + console.error("Error fetching image:", error); + }); + }; + + // Whenever user clicks the Details tab, fetch images + const handleDetailsClick = () => { + setActiveTab("details"); + fetchItemImages(); + }; + + return ( +
+ {/* Modal backdrop + box */} +
+ {/* Header with Tabs and Close button */} + + + {/* Body */} +
+ {activeTab === "details" && ( +
+ {/* Reference */} +
+

+ Ref. # {selectedShipment.shipment_reference} +

+
+ + {/* Items Section */} +
+

Items

+ {selectedShipment.items.map((item) => ( +
+ +
+ +
+ {(item.price / item.quantity).toFixed(2)} {selectedShipment.currency} +
+
+
+
+
+

Model Number:

+

{item.model_number}

+
+
+

Origin:

+

{item.originCountry}

+
+
+

Weight:

+

{item.weight}

+
+
+ {item.stockData && Object.keys(item.stockData).length > 0 && ( +
+

Stock Information:

+ {Object.entries(item.stockData).flatMap(([stockName, stockArray]) => + stockArray.map((stock, idx) => ( +
+

{stockName}

+

Location: {stock.location}

+

Count: {stock.count} ks

+
+ )) + )} +
+ )} +
+
+ ))} +
+ + {/* Delivery Address */} +
+

Delivery Address

+
+

+ Name: {selectedShipment.delivery_address_name} +

+ {selectedShipment.delivery_address_company_name && ( +

+ Company: {selectedShipment.delivery_address_company_name} +

+ )} +

+ Street: {selectedShipment.delivery_address_street_name}{" "} + {selectedShipment.delivery_address_street_number} +

+

+ City: {selectedShipment.delivery_address_city} +

+

+ ZIP: {selectedShipment.delivery_address_zip} +

+ {selectedShipment.delivery_address_state_iso && ( +

+ State: {selectedShipment.delivery_address_state_iso} +

+ )} +

+ Country: {selectedShipment.delivery_address_country_iso} +

+
+
+ + {/* Carrier & Shipment Info */} +
+

Carrier & Shipment Info

+
+
+

Carrier:

+

{selectedShipment.carrier.carrier_name}

+
+
+

Tracking Number:

+

{selectedShipment.shipment?.tracking_number || ""}

+
+
+

Shipment Price:

+

+ {selectedShipment.currency} {selectedShipment.shipment_price} +

+
+
+

Shipment Value:

+

{selectedShipment.shipment_value}

+
+
+

Weight:

+

{selectedShipment.weight}

+
+
+

Dimensions:

+

+ {selectedShipment.length} × {selectedShipment.width} × {selectedShipment.height} +

+
+
+
+
+ )} + + {activeTab === "stock" && ( +
+ +
+ )} +
+ + {/* Footer with action button */} +
+ {selectedType === "parcels" && ( + + )} + {selectedType === "processed" && ( + + )} + +
+
+
+ ); +} diff --git a/resources/js/Components/modals/BatchInfoModal.tsx b/resources/js/Components/modals/BatchInfoModal.tsx new file mode 100644 index 0000000..42001a4 --- /dev/null +++ b/resources/js/Components/modals/BatchInfoModal.tsx @@ -0,0 +1,35 @@ +// components/modals/BatchInfoModal.tsx +import React from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' +import { StockBatch, StockEntry } from "@/types" +import BatchInfoWindow from "@/Components/BatchInfoWindow"; +import {router} from "@inertiajs/react"; + +interface Props { + onClose: () => void + selectedBatch: () => StockBatch +} + + +const BatchInfoModal: React.FC = ({ onClose, selectedBatch }) => { + + + return ( +
+

Batch # {selectedBatch.id}

+ + route(name, params)} + entries={null} + openEntry={() => {}} + recountEnabled={false} + /> + +
+ ) +} + +export default BatchInfoModal diff --git a/resources/js/Components/modals/ChangeCountModal.tsx b/resources/js/Components/modals/ChangeCountModal.tsx new file mode 100644 index 0000000..98f4315 --- /dev/null +++ b/resources/js/Components/modals/ChangeCountModal.tsx @@ -0,0 +1,210 @@ +import React from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' +import { StockPosition, StockEntry } from '@/types' + +interface Props { + onClose: () => void + selectedPosition: StockPosition +} + +const ChangeCountModal: React.FC = ({ onClose, selectedPosition }) => { + const [selectedEntry, setSelectedEntry] = React.useState(null) + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false) + const [newCount, setNewCount] = React.useState(null) + const [loading, setLoading] = React.useState(false) + + + const handleChangeCount = async () => { + + setLoading(true) + const toastId = toast.loading('Changing count…') + + try { + const { data } = await axios.post( + '/api/pdaView/changeCount', + { + entry_id: selectedEntry.id, + section_id: selectedEntry.pivot.section_id, + count: selectedEntry.pivot.count, + new_count: newCount, + }, + { withCredentials: true } + ) + + if (!data.success) { + toast.dismiss(toastId) + switch (data.error) { + case 'validation_failed': + toast.error('Validation failed. Check inputs.') + break + case 'not_found': + toast.error('Entry or section not found.') + break + case 'section_occupied': + toast.error('Target section is already occupied.') + break + case 'insufficient_capacity': + toast.error('Not enough capacity in target section.') + break + default: + toast.error(data.message ?? 'Server error during count change.') + break + } + setLoading(false) + return + } + + toast.dismiss(toastId) + toast.success('Count changed on position.') + resetAndClose() + } catch (err: any) { + toast.dismiss() + if (err.response && err.response.data) { + const payload = err.response.data + if (payload.error === 'validation_failed') { + toast.error('Validation failed. Check inputs.') + } else if (payload.error === 'not_found') { + toast.error('Entry or section not found.') + } else if (payload.error === 'section_occupied') { + toast.error('Target section is already occupied.') + } else if (payload.error === 'insufficient_capacity') { + toast.error('Not enough capacity in target section.') + } else { + toast.error(payload.message || 'Unknown error occurred.') + } + } else { + toast.error('Network error. Please try again.') + } + setLoading(false) + } + } + + const resetAndClose = () => { + setSelectedEntry(null) + setNewCount(null) + setLoading(false) + setIsDropdownOpen(false) + onClose() + } + + // Build flat list of entries grouped by section + const entriesList = React.useMemo(() => { + if (!selectedPosition.sections) return [] + return selectedPosition.sections.flatMap((section) => + section.entries.map((entry) => ({ ...entry, pivot: entry.pivot, _section: section })) + ) + }, [selectedPosition]) + console.log(entriesList); + + return ( +
+

Change item count

+ + {/* Entry Dropdown */} +
+ +
+ +
    + {entriesList.map((entry) => ( +
  • + +
  • + ))} + {entriesList.length === 0 && ( +
  • + No items in this position +
  • + )} +
+
+ {selectedEntry ? ( + + ): ""} +
+ + {/* Simulate Scan Button */} +
+ +
+ + {/* Waiting for scan indicator */} + {selectedEntry && newCount === null && !loading && ( +
+ Waiting for section scan... + +
+ )} + + {/* Cancel Button */} +
+ +
+
+ ) +} + +export default ChangeCountModal diff --git a/resources/js/Components/modals/CountStockModal.tsx b/resources/js/Components/modals/CountStockModal.tsx index 6fcc342..1f5f47c 100644 --- a/resources/js/Components/modals/CountStockModal.tsx +++ b/resources/js/Components/modals/CountStockModal.tsx @@ -2,43 +2,192 @@ import React from 'react' import axios from 'axios' import { toast } from 'react-hot-toast' +import { StockBatch, StockEntry } from "@/types" interface Props { onClose: () => void + selectedBatch: () => StockBatch } -const CountStockModal: React.FC = ({ onClose }) => { - const [quantity, setQuantity] = React.useState(0) +const CountStockModal: React.FC = ({ onClose, selectedBatch }) => { + const [quantity, setQuantity] = React.useState("") + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false) + const [selectedEntry, setSelectedEntry] = React.useState(null) + + const handleSelect = (entry: StockEntry) => { + setSelectedEntry(entry) + setIsDropdownOpen(false) + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + + if (!selectedEntry) { + toast.error("Please select a product first.") + return + } + try { - await axios.post('/api/set-stock', { quantity }, { withCredentials: true }) - toast.success('Stock set!') + const { data } = await axios.post( + "/api/pdaView/countStock", + { + entryId: selectedEntry.id, + quantity, + }, + { withCredentials: true } + ) + + // If the HTTP status was 200 but success===false, + // inspect data.error to decide which toast to show + if (!data.success) { + switch (data.error) { + case "already_counted": + toast.error("This item has already been counted.") + break + case "validation_failed": + toast.error("Validation failed. Please check your inputs.") + break + case "not_found": + toast.error("Could not find that stock entry.") + break + case "server_error": + default: + // show the message from the server if provided, otherwise fallback + toast.error( + data.message ?? "Something went wrong on the server." + ) + break + } + return + } + + // success === true: + toast.success("Stock counted!") onClose() - } catch { - toast.error('Failed to set stock') + } catch (err: any) { + // If the request itself failed (e.g. network or HTTP 500 that didn't return JSON): + // You can inspect err.response.status if you want, e.g. 409 → extract JSON, etc. + if (err.response && err.response.data) { + // Attempt to read the server’s JSON error payload + const payload = err.response.data + if (payload.error === "already_counted") { + toast.error("This item has already been counted.") + return + } + if (payload.error === "validation_failed") { + toast.error("Validation failed. Please check your inputs.") + return + } + if (payload.error === "not_found") { + toast.error("Could not find that stock entry.") + return + } + // Fallback to any message string + toast.error(payload.message || "Unknown error occurred.") + return + } + + // Otherwise, a true “network” or unexpected error: + toast.error("Failed to count stock. Please try again.") } } return ( -
-

Set Stock

- - setQuantity(+e.target.value)} - className="input input-bordered w-full" - required - /> -
+ +

Count Stock

+ + {/* Product Dropdown */} +
+ + +
+ + +
    + {selectedBatch.stock_entries.map((entry) => ( +
  • + +
  • + ))} + {selectedBatch.stock_entries.length === 0 && ( +
  • + No items available +
  • + )} +
+
+
+ + {/* Quantity Input */} +
+ + setQuantity(+e.target.value)} + className="input input-bordered w-full" + required + /> +
+ + {/* Actions */} +
-
diff --git a/resources/js/Components/modals/EditRoomModal.tsx b/resources/js/Components/modals/EditRoomModal.tsx new file mode 100644 index 0000000..5cf0417 --- /dev/null +++ b/resources/js/Components/modals/EditRoomModal.tsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { toast } from 'react-hot-toast'; +import { StockRoom } from '@/types'; +import EditStockSections from '@/Components/modals/EditStockSections'; + +interface Props { + room: StockRoom; + onClose: () => void; + onUpdated: () => void; +} + +export default function EditRoomModal({ room, onClose, onUpdated }: Props) { + const [data, setData] = useState(room); + const [activePosition, setActivePosition] = useState<() => any | null>(null); + + useEffect(() => { + setData(room); + }, [room]); + + const reload = async () => { + try { + const resp = await axios.get(`/api/stock-rooms/${room.room_id}`, { withCredentials: true }); + setData(resp.data); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Failed to reload room'); + } + }; + + const addLine = async () => { + try { + const newIndex = data.lines.length + 1; + await axios.post('/api/stock-lines', { + room_id: room.room_id, + line_symbol: `${newIndex}`, + line_name: `Line ${newIndex}` + }, { withCredentials: true }); + await reload(); + toast.success('Line added'); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Failed to add line'); + } + }; + + const addRack = async (line_id: number, line_symbol: string, line_name: string) => { + try { + const line = data.lines.find(l => l.line_id === line_id); + const newIndex = line?.racks.length! + 1; + await axios.post('/api/stock-racks', { + line_id, + rack_symbol: `${newIndex}`, + rack_name: `Rack ${newIndex}` + }, { withCredentials: true }); + await reload(); + toast.success('Rack added'); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Failed to add rack'); + } + }; + + const addShelf = async (rack_id: number, rack_symbol: string, rack_name: string) => { + try { + const rack = data.lines + .flatMap(l => l.racks) + .find(r => r.rack_id === rack_id); + const newIndex = rack?.shelves.length! + 1; + await axios.post('/api/stock-shelves', { + rack_id, + shelf_symbol: `${newIndex}`, + shelf_name: `Shelf ${newIndex}` + }, { withCredentials: true }); + await reload(); + toast.success('Shelf added'); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Failed to add shelf'); + } + }; + + const addPosition = async (shelf_id: number, shelf_symbol: string, shelf_name: string) => { + try { + const shelf = data.lines + .flatMap(l => l.racks) + .flatMap(r => r.shelves) + .find(s => s.shelf_id === shelf_id); + const newIndex = shelf?.positions.length! + 1; + await axios.post('/api/stock-positions', { + shelf_id, + position_symbol: `${newIndex}`, + position_name: `Position ${newIndex}`, + capacity: 0 + }, { withCredentials: true }); + await reload(); + toast.success('Position added'); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Failed to add position'); + } + }; + + return ( +
+

+ Edit Room: {data.room_name} + +

+ +
+ {data.lines.map(line => ( +
+ + {line.line_name} + + + + {line.racks.map(rack => ( +
+ + {rack.rack_name} + + + + {rack.shelves.map(shelf => ( +
+ + {shelf.shelf_name} + + + +
    + {shelf.positions.map(pos => ( +
  • + {pos.position_name} + +
  • + ))} +
+
+ ))} +
+ ))} +
+ ))} +
+ +
+ +
+ + {activePosition && ( + { + setActivePosition(null); + reload(); + }} + /> + )} +
+ ); +} diff --git a/resources/js/Components/modals/EditStockSections.tsx b/resources/js/Components/modals/EditStockSections.tsx new file mode 100644 index 0000000..63d29f5 --- /dev/null +++ b/resources/js/Components/modals/EditStockSections.tsx @@ -0,0 +1,347 @@ +// components/modals/EditStockSections.tsx +import React from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' +import { StockPosition, StockSection } from "@/types" + +interface Props { + onClose: () => void + selectedPosition: () => StockPosition +} + +interface EditableSection { + // Mirror of StockSection, with local-only flags + section_id?: number + section_symbol: string + section_name: string + capacity: number + retrievable: boolean + markedForDeletion: boolean +} + +const EditStockSections: React.FC = ({ onClose, selectedPosition }) => { + + // Local state for position capacity and editable sections + const [positionCapacity, setPositionCapacity] = React.useState(selectedPosition.capacity) + const [sections, setSections] = React.useState([]) + const [loading, setLoading] = React.useState(false) + const [validationError, setValidationError] = React.useState("") + + // Initialize sections state whenever the selected position changes + React.useEffect(() => { + const initialSections: EditableSection[] = selectedPosition.sections.map((sec) => ({ + section_id: sec.section_id, + section_symbol: sec.section_symbol, + section_name: sec.section_name, + capacity: sec.capacity, + retrievable: sec.retrievable, + markedForDeletion: false, + })) + setSections(initialSections) + setPositionCapacity(selectedPosition.capacity) + setValidationError("") + }, [selectedPosition]) + + // Handler: change position capacity + const handlePositionCapacityChange = (e: React.ChangeEvent) => { + const val = parseInt(e.target.value, 10) + if (!isNaN(val) && val >= 0) { + setPositionCapacity(val) + // Clear any existing validation error to re-validate + setValidationError("") + } + } + + // Handler: change a section's field (by index) + const handleSectionChange = ( + index: number, + field: keyof Omit, + value: string | boolean + ) => { + setSections((prev) => { + const updated = [...prev] + if (field === "capacity") { + const num = parseInt(value as string, 10) + updated[index].capacity = isNaN(num) ? 0 : num + } else if (field === "retrievable") { + updated[index].retrievable = value as boolean + } else { + updated[index][field] = value as string + } + return updated + }) + setValidationError("") + } + + // Handler: add a brand-new empty section + const handleAddSection = () => { + setSections((prev) => [ + ...prev, + { + section_symbol: `${prev.length + 1}`, + section_name: `Section ${prev.length + 1}`, + capacity: 0, + retrievable: true, + markedForDeletion: false, + }, + ]) + setValidationError("") + } + + // Handler: mark a section for deletion (or directly remove if never saved) + const handleRemoveSection = (index: number) => { + setSections((prev) => { + const updated = [...prev] + if (updated[index].section_id) { + // If it exists on the server, mark for deletion + updated[index].markedForDeletion = true + } else { + // If it's a new unsaved section, just drop it + updated.splice(index, 1) + } + return updated + }) + setValidationError("") + } + + // Compute total capacity of non-deleted sections + const totalSectionsCapacity = sections + .filter((s) => !s.markedForDeletion) + .reduce((sum, s) => sum + s.capacity, 0) + + // Validation: total section capacity must not exceed position capacity + React.useEffect(() => { + if (totalSectionsCapacity > positionCapacity) { + setValidationError( + `Total section capacity (${totalSectionsCapacity}) exceeds position capacity (${positionCapacity}).` + ) + } else { + setValidationError("") + } + }, [totalSectionsCapacity, positionCapacity]) + + // Handler: save all changes (position + sections) + const handleSave = async () => { + // Prevent saving if there's a validation error + if (validationError) { + toast.error(validationError) + return + } + + setLoading(true) + const toastId = toast.loading("Saving changes…") + + try { + // 1) Update the StockPosition's capacity if it has changed + if (positionCapacity !== selectedPosition.capacity) { + await axios.put( + `/api/stockPositions/${selectedPosition.position_id}`, + { + capacity: positionCapacity, + }, + { withCredentials: true } + ) + } + + // 2) Process each section: create, update, or delete + for (const sec of sections) { + if (sec.section_id && sec.markedForDeletion) { + // DELETE existing section + await axios.delete(`/api/stockSections/${sec.section_id}`, { + withCredentials: true, + }) + } else if (sec.section_id) { + // UPDATE existing section + await axios.put( + `/api/stockSections/${sec.section_id}`, + { + section_name: sec.section_name, + capacity: sec.capacity, + retrievable: sec.retrievable, + }, + { withCredentials: true } + ) + } else if (!sec.section_id && !sec.markedForDeletion) { + // CREATE new section + await axios.post( + `/api/stockSections`, + { + position_id: selectedPosition.position_id, + section_symbol: sec.section_symbol, + section_name: sec.section_name, + capacity: sec.capacity, + retrievable: sec.retrievable, + }, + { withCredentials: true } + ) + } + // If a new section was added then immediately marked for deletion before save, + // we do nothing (skip). + } + + toast.dismiss(toastId) + toast.success("Position and sections updated successfully.") + onClose() + } catch (err: any) { + toast.dismiss(toastId) + if (err.response && err.response.data && err.response.data.message) { + toast.error(`Error: ${err.response.data.message}`) + } else { + toast.error("Network or server error. Please try again.") + } + } finally { + setLoading(false) + } + } + + return ( +
+

EditPosition {selectedPosition.storage_address}

+ + {/* Position Capacity Input */} +
+ + +
+ + {/* Sections List */} +
+
+

Sections

+ +
+ + {sections.map((sec, idx) => ( +
+
+ + {sec.section_id + ? `Section ID: ${sec.section_id}` + : `New Section`} + + +
+ {!sec.markedForDeletion && ( +
+ {/* Section Name */} +
+ + + handleSectionChange(idx, "section_name", e.target.value) + } + className="input input-bordered w-full" + required + /> +
+ + {/* Section Capacity */} +
+ + + handleSectionChange(idx, "capacity", e.target.value) + } + className="input input-bordered w-full" + min="0" + required + /> +
+ + {/* Retrievable Checkbox */} +
+ +
+
+ )} + {sec.markedForDeletion && ( +
+ This section will be deleted when you save. +
+ )} +
+ ))} + + {sections.length === 0 && ( +
+ No sections defined for this position. +
+ )} +
+ + {/* Validation Error */} + {validationError && ( +
{validationError}
+ )} +
+ Total of section capacities: {totalSectionsCapacity} +
+ + {/* Action Buttons */} +
+ + +
+
+ ) +} + +export default EditStockSections diff --git a/resources/js/Components/modals/MoveSectionModal.tsx b/resources/js/Components/modals/MoveSectionModal.tsx new file mode 100644 index 0000000..81e292b --- /dev/null +++ b/resources/js/Components/modals/MoveSectionModal.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react' +import axios from 'axios' +import { toast } from 'react-hot-toast' +import { StockPosition, StockSection } from '@/types' + +interface Props { + onClose: () => void + fromPosition?: StockPosition | null + fromSection?: StockSection | null + toPosition?: StockPosition | null + toSection?: StockSection | null +} + +const MoveSectionModal: React.FC = ({ + onClose, + fromSection, + toSection, + }) => { + const [loading, setLoading] = useState(false) + + const handleMove = async () => { + if (!fromSection || !toSection) return + setLoading(true) + const toastId = toast.loading('Moving…') + + try { + const { data } = await axios.post( + '/api/pdaView/moveStockSection', + { + entry_id: fromSection.entries[0].id, + section_id: fromSection.section_id, + new_section_id: toSection.section_id, + count: fromSection.entries[0].pivot.count, + }, + { withCredentials: true } + ) + + if (!data.success) { + toast.dismiss(toastId) + switch (data.error) { + case 'validation_failed': + toast.error('Validation failed. Check inputs.') + break + case 'not_found': + toast.error('Entry or section not found.') + break + case 'section_occupied': + toast.error('Target section is already occupied.') + break + case 'insufficient_capacity': + toast.error('Not enough capacity in target section.') + break + default: + toast.error(data.message ?? 'Server error during moving.') + break + } + setLoading(false) + return + } + + toast.dismiss(toastId) + toast.success('Moved successfully.') + onClose() + } catch (err: any) { + toast.dismiss() + if (err.response && err.response.data) { + const payload = err.response.data + if (payload.error === 'validation_failed') { + toast.error('Validation failed. Check inputs.') + } else if (payload.error === 'not_found') { + toast.error('Entry or section not found.') + } else if (payload.error === 'section_occupied') { + toast.error('Target section is already occupied.') + } else if (payload.error === 'insufficient_capacity') { + toast.error('Not enough capacity in target section.') + } else { + toast.error(payload.message || 'Unknown error occurred.') + } + } else { + if(fromSection.entries?.length <= 0) { + toast.error('No items in the FROM section.'); + } + else { + toast.error('Network error. Please try again.'); + } + } + console.error('MoveSectionModal error:', err) + setLoading(false) + } + } + + return ( +
+

Move Stock Item

+ + {!fromSection && ( +
+ Please scan the TO section… +
+ )} + + {fromSection && !toSection && ( +
+ Scanned FROM: {fromSection.section_id}. Now scan the TO section… +
+ )} + + {fromSection && toSection && ( +
+ Ready to move from {fromSection.section_id} → {toSection.section_id} +
+ )} + +
+ + +
+
+ ) +} + +export default MoveSectionModal diff --git a/resources/js/Components/modals/NewRoomModal.tsx b/resources/js/Components/modals/NewRoomModal.tsx new file mode 100644 index 0000000..bb285ee --- /dev/null +++ b/resources/js/Components/modals/NewRoomModal.tsx @@ -0,0 +1,82 @@ +// resources/js/Components/Modals/NewRoomModal.tsx +import React, { useState } from 'react'; +import axios from 'axios'; +import { toast } from 'react-hot-toast'; + +interface Props { + onClose: () => void; + onSaved: () => void; +} + +export default function NewRoomModal({ onClose, onSaved }: Props) { + const [form, setForm] = useState({ + room_symbol: '', + room_name: '', + lines: 1, + racks_per_line: 1, + shelves_per_rack: 1, + positions_per_shelf: 1, + }); + const [loading, setLoading] = useState(false); + + const handleChange = (e: React.ChangeEvent) => + setForm({ ...form, [e.target.name]: e.target.type === 'number' + ? parseInt(e.target.value) + : e.target.value }); + + const save = async () => { + setLoading(true); + const id = toast.loading('Creating room…'); + try { + await axios.post('/api/stock-rooms', form, { withCredentials: true }); + toast.dismiss(id); + toast.success('Room created!'); + onSaved(); + } catch (err: any) { + toast.dismiss(id); + toast.error(err.response?.data?.message || 'Error creating room'); + } finally { + setLoading(false); + } + }; + + return ( +
+

New Room

+
+ {['room_symbol','room_name'].map((field) => ( +
+ + +
+ ))} + {['lines','racks_per_line','shelves_per_rack','positions_per_shelf'].map((field) => ( +
+ + +
+ ))} +
+
+ + +
+
+ + ); +} diff --git a/resources/js/Components/modals/SetStockModal.tsx b/resources/js/Components/modals/SetStockModal.tsx index 5935a48..ecbc514 100644 --- a/resources/js/Components/modals/SetStockModal.tsx +++ b/resources/js/Components/modals/SetStockModal.tsx @@ -1,48 +1,391 @@ -// components/modals/SetStockModal.tsx import React from 'react' import axios from 'axios' -import { toast } from 'react-hot-toast' +import {toast} from 'react-hot-toast' +import {StockBatch, StockEntry, StockPosition, StockSection} from '@/types' +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' +import {faCheckCircle, faTimesCircle} from '@fortawesome/free-solid-svg-icons' interface Props { onClose: () => void + selectedBatch: StockBatch + selectedSection: StockSection | null + selectedPosition: StockPosition | null } -const SetStockModal: React.FC = ({ onClose }) => { - const [quantity, setQuantity] = React.useState(0) +const SetStockModal: React.FC = ({onClose, selectedBatch, selectedSection, selectedPosition}) => { + const [quantity, setQuantity] = React.useState('') + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false) + const [selectedEntry, setSelectedEntry] = React.useState(null) + const [loading, setLoading] = React.useState(false) + const [isSuggestionsOpen, setIsSuggestionsOpen] = React.useState(false) + const [activeTab, setActiveTab] = React.useState<'batch' | 'item'>('item') + const handleSelect = (entry: StockEntry) => { + setSelectedEntry(entry) + setIsDropdownOpen(false) + } + + const parsedQty = parseInt(quantity, 10) + const isNumber = !isNaN(parsedQty) + const maxItem = selectedEntry ? selectedEntry.count - selectedEntry.count_stocked : 0 + const capacity = selectedSection ? selectedSection.capacity : 0 + const exceedsItem = isNumber && parsedQty > maxItem + const exceedsCapacity = isNumber && parsedQty > capacity + const hasError = exceedsItem || exceedsCapacity + + const readyToScan = + selectedEntry !== null && + isNumber && + parsedQty >= 1 && + !exceedsItem && + !exceedsCapacity + + // Called when “Simulate Scan (ID=3)” is clicked + const handleScan = async () => { + + if(activeTab === 'batch') { + if (!selectedBatch || !selectedPosition) return + } + else { + if (!selectedEntry || !selectedSection) return + if (!readyToScan) { + toast.error('Please fix validation errors before confirming.') + return + } + } + + + setLoading(true) + const toastId = toast.loading('Storing…') - 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') + let response + if (activeTab === 'batch') { + response = await axios.post( + '/api/pdaView/setBatchSection', + { + batch_id: selectedBatch.id, + position_id: selectedPosition?.position_id, + }, + { withCredentials: true } + ) + } else { + response = await axios.post( + '/api/pdaView/setStockSection', + { + entry_id: selectedEntry?.id, + section_id: selectedSection?.section_id, + count_to_be_stored: parsedQty, + }, + { withCredentials: true } + ) + } + + // now 'response' is defined no matter which branch ran + const { data } = response + console.log('data', data) + + + if (!data.success) { + toast.dismiss(toastId) + switch (data.error) { + case 'validation_failed': + toast.error('Validation failed. Check inputs.') + break + case 'not_found': + toast.error('Entry or section not found.') + break + case 'section_occupied': + toast.error('That section is already occupied.') + break + case 'insufficient_capacity': + toast.error('Not enough capacity in this section.') + break + case 'server_error': + default: + toast.error(data.message ?? 'Server error during storing.') + break + } + setLoading(false) + return + } + + toast.dismiss(toastId) + toast.success('Stored successfully.') + resetAndClose() + } catch (err: any) { + toast.dismiss() + if (err.response && err.response.data) { + const payload = err.response.data + if (payload.error === 'validation_failed') { + toast.error('Validation failed. Check inputs.') + } else if (payload.error === 'not_found') { + toast.error('Entry or section not found.') + } else if (payload.error === 'section_occupied') { + toast.error('That section is already occupied.') + } else if (payload.error === 'insufficient_capacity') { + toast.error('Not enough capacity in this section.') + } else { + toast.error(payload.message || 'Unknown error occurred.') + } + } else { + toast.error('Network error. Please try again.') + } + setLoading(false) } } + const resetAndClose = () => { + setQuantity('') + setSelectedEntry(null) + setLoading(false) + setIsSuggestionsOpen(false) + setIsDropdownOpen(false) + onClose() + } + + return ( - -

Set Stock

- - setQuantity(+e.target.value)} - className="input input-bordered w-full" - required - /> -
- -
- + + {activeTab === 'batch' && ( +
+ {(selectedSection || selectedPosition) && ( +
+ Selected position: {selectedPosition ? selectedPosition.storage_address : selectedSection ? selectedSection.position.storage_address : ""} +
+ )} + +
+ {loading ? 'Storing…' : (selectedSection || selectedPosition) ? 'Waiting for confirm' : 'Waiting for section scan...'} + +
+ +
+ + +
+ +
+ )} + + {activeTab === 'item' && ( +
+ {/* Product Dropdown */} +
+ +
+ + +
    + {selectedBatch.stock_entries.map((entry) => { + const stocked = entry.count_stocked + const total = entry.count + const remaining = total - stocked + const selectable = remaining > 0 + + return ( +
  • + +
  • + ) + })} + {selectedBatch.stock_entries.length === 0 && ( +
  • + No items available +
  • + )} +
+
+
+ + {/* Quantity Input */} +
+ + setQuantity(e.target.value)} + className={`input input-bordered w-full ${hasError ? 'border-red-500' : ''}`} + min="1" + max={selectedEntry ? maxItem : undefined} + required + /> + {selectedEntry && ( + + Max items: {maxItem} | Capacity: {capacity} + + )} + + {/* Validation Messages */} + {(exceedsItem || exceedsCapacity) && ( +
+ {exceedsItem && ( +
+ + Item quantity exceeded +
+ )} + {exceedsCapacity && ( +
+ + Maximum capacity exceeded +
+ )} +
+ )} + + {/* Success indicator if valid */} + {readyToScan && ( +
+ + Ready to store +
+ )} +
+ + {selectedSection && ( +
+ Selected section: {selectedSection.storage_address} +
+ )} + + {/* Buttons: Suggested Sections & Simulate Scan */} +
+ + +
+ + {/* Waiting for scan indicator */} + {readyToScan && !loading && ( +
+ {loading ? 'Storing…' : selectedSection ? 'Waiting for confirm' : 'Waiting for section scan...'} + +
+ )} + + {/* Cancel Button */} +
+ +
+ + {/* Suggested Sections Modal */} + {isSuggestionsOpen && ( +
+
+

Suggested Sections

+
    +
  • Section A (ID: 1)
  • +
  • Section B (ID: 2)
  • +
  • Section C (ID: 3)
  • +
  • Section D (ID: 4)
  • +
+
+ +
+
+
+ )} +
+ )} + + +
) } diff --git a/resources/js/Components/modals/SetStockModalFromTemp.tsx b/resources/js/Components/modals/SetStockModalFromTemp.tsx new file mode 100644 index 0000000..52cf3c2 --- /dev/null +++ b/resources/js/Components/modals/SetStockModalFromTemp.tsx @@ -0,0 +1,342 @@ +import React from 'react' +import axios from 'axios' +import {toast} from 'react-hot-toast' +import {StockBatch, StockEntry, StockPosition, StockSection} from '@/types' +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' +import {faCheckCircle, faTimesCircle} from '@fortawesome/free-solid-svg-icons' + +interface Props { + onClose: () => void + selectedSection: StockSection | null + selectedPosition: StockPosition | null +} + +const SetStockModal: React.FC = ({onClose, selectedSection, selectedPosition}) => { + const [quantity, setQuantity] = React.useState('') + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false) + const [selectedEntry, setSelectedEntry] = React.useState(null) + const [loading, setLoading] = React.useState(false) + const [isSuggestionsOpen, setIsSuggestionsOpen] = React.useState(false) + const [activeTab, setActiveTab] = React.useState<'batch' | 'item'>('item') + const handleSelect = (entry: StockEntry) => { + setSelectedEntry(entry) + setIsDropdownOpen(false) + } + + const parsedQty = parseInt(quantity, 10) + const isNumber = !isNaN(parsedQty) + const capacity = selectedSection ? selectedSection.capacity : 0 + const exceedsItem = isNumber && parsedQty > selectedEntry?.count + const exceedsCapacity = isNumber && parsedQty > capacity + const hasError = exceedsItem || exceedsCapacity + + const readyToScan = + selectedEntry !== null && + isNumber && + parsedQty >= 1 && + !exceedsItem && + !exceedsCapacity + + // Called when “Simulate Scan (ID=3)” is clicked + const handleScan = async () => { + + + if (!selectedEntry || !selectedSection) return + if (!readyToScan) { + toast.error('Please fix validation errors before confirming.') + return + } + + + setLoading(true) + const toastId = toast.loading('Storing…') + + try { + let response + + response = await axios.post( + '/api/pdaView/storeStock', + { + entry_id: selectedEntry?.id, + section_id: selectedSection?.section_id, + current_section: selectedEntry?.sections[0].section_id, + count_to_be_stored: parsedQty, + }, + {withCredentials: true} + ) + + const {data} = response + console.log('data', data) + + + if (!data.success) { + toast.dismiss(toastId) + switch (data.error) { + case 'validation_failed': + toast.error('Validation failed. Check inputs.') + break + case 'not_found': + toast.error('Entry or section not found.') + break + case 'section_occupied': + toast.error('That section is already occupied.') + break + case 'insufficient_capacity': + toast.error('Not enough capacity in this section.') + break + case 'server_error': + default: + toast.error(data.message ?? 'Server error during storing.') + break + } + setLoading(false) + return + } + + toast.dismiss(toastId) + toast.success('Stored successfully.') + resetAndClose() + } catch (err: any) { + toast.dismiss() + if (err.response && err.response.data) { + const payload = err.response.data + if (payload.error === 'validation_failed') { + toast.error('Validation failed. Check inputs.') + } else if (payload.error === 'not_found') { + toast.error('Entry or section not found.') + } else if (payload.error === 'section_occupied') { + toast.error('That section is already occupied.') + } else if (payload.error === 'insufficient_capacity') { + toast.error('Not enough capacity in this section.') + } else { + toast.error(payload.message || 'Unknown error occurred.') + } + } else { + toast.error('Network error. Please try again.') + } + setLoading(false) + } + } + + const resetAndClose = () => { + setQuantity('') + setSelectedEntry(null) + setLoading(false) + setIsSuggestionsOpen(false) + setIsDropdownOpen(false) + onClose() + } + + + const sectionEntries = React.useMemo(() => { + // 1) Strip out the `entries` field from each section + const sectionsWithoutEntries = + selectedPosition?.sections.map(({entries, ...sec}) => sec) || [] + + // 2) Flatten all entries, and tag each with: + // - the cleaned-up sections array + // - its own sectionId / sectionAddress + return ( + selectedPosition?.sections.flatMap((section) => + section.entries.map((entry) => ({ + ...entry, + sections: sectionsWithoutEntries, + sectionId: section.section_id, + sectionAddress: section.storage_address, + })) + ) || [] + ) + }, [selectedPosition]) + + + return ( +
+

Store Stock

+ +
+ {/* Product Dropdown */} +
+ +
+ + +
    + {sectionEntries.map((entry) => { + const stocked = entry.count_stocked + const total = entry.count + const remaining = total - stocked + + return ( +
  • + +
  • + ) + })} + {sectionEntries.length === 0 && ( +
  • + No items available +
  • + )} +
+
+
+ + {/* Quantity Input */} +
+ + setQuantity(e.target.value)} + className={`input input-bordered w-full ${hasError ? 'border-red-500' : ''}`} + min="1" + max={selectedEntry?.count} + required + /> + {selectedEntry && ( + + Max items: {selectedEntry?.count} | Capacity: {capacity} + + )} + + {/* Validation Messages */} + {(exceedsItem || exceedsCapacity) && ( +
+ {exceedsItem && ( +
+ + Item quantity exceeded +
+ )} + {exceedsCapacity && ( +
+ + Maximum capacity exceeded +
+ )} +
+ )} + + {/* Success indicator if valid */} + {readyToScan && ( +
+ + Ready to store +
+ )} +
+ + {selectedSection && ( +
+ Selected section: {selectedSection.storage_address} +
+ )} + + {/* Buttons: Suggested Sections & Simulate Scan */} +
+ + +
+ + {/* Waiting for scan indicator */} + {readyToScan && !loading && ( +
+ {loading ? 'Storing…' : selectedSection ? 'Waiting for confirm' : 'Waiting for section scan...'} + +
+ )} + + {/* Cancel Button */} +
+ +
+ + {/* Suggested Sections Modal */} + {isSuggestionsOpen && ( +
+
+

Suggested Sections

+
    +
  • Section A (ID: 1)
  • +
  • Section B (ID: 2)
  • +
  • Section C (ID: 3)
  • +
  • Section D (ID: 4)
  • +
+
+ +
+
+
+ )} +
+ + +
+ ) +} + +export default SetStockModal diff --git a/resources/js/Pages/Expedice.tsx b/resources/js/Pages/Expedice.tsx new file mode 100644 index 0000000..5b45345 --- /dev/null +++ b/resources/js/Pages/Expedice.tsx @@ -0,0 +1,534 @@ +import React, { + useEffect, + useState, + useRef, + forwardRef, + useImperativeHandle, +} from "react"; +import { usePage } from "@inertiajs/react"; +import axios from "axios"; +import { + faSquareCheck, + faTruck, + faXmark, + faBarcode, faArrowLeft, faQuestionCircle, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Item, ShipmentRequest } from "@/interfaces/interfaces"; +import WarehouseExpediceDialog from "@/Components/WarehouseExpediceDialog"; +import PdaView from "@/Pages/PdaView"; + +// Define an interface for the imperative handle exposed by a row. +interface RowHandle { + triggerAnimation: () => void; +} + +// ------------------- ParcelRow (for marking as processed) ------------------- +interface ParcelRowProps { + shipment: ShipmentRequest; + onProcess: (shipment: ShipmentRequest) => void; + onOpenDialog: (shipment: ShipmentRequest, type: "parcels") => void; +} + +const ParcelRow = forwardRef( + ({ shipment, onProcess, onOpenDialog }, ref) => { + // "none" | "bounce" | "fade" + const [animationPhase, setAnimationPhase] = useState< + "none" | "bounce" | "fade" + >("none"); + + // Expose a method to trigger the animation from the parent (dialog) + useImperativeHandle(ref, () => ({ + triggerAnimation: () => { + setAnimationPhase("bounce"); + setTimeout(() => { + setAnimationPhase("fade"); + }, 1000); + setTimeout(() => { + onProcess(shipment); + }, 2000); + }, + })); + + const handleRowClick = () => { + onOpenDialog(shipment, "parcels"); + }; + + const handleProcess = (e: React.MouseEvent) => { + e.stopPropagation(); + setAnimationPhase("bounce"); + setTimeout(() => { + setAnimationPhase("fade"); + }, 1000); + setTimeout(() => { + onProcess(shipment); + }, 2000); + }; + + /* Determine classes based on the animation phase: + - "none": border-base-300, bg-base-100, text-base-content + - "bounce": border-primary, bg-primary, text-primary-content + - "fade": border-primary, bg-primary, text-primary-content, opacity-0 + */ + const containerClasses = ` + p-4 border rounded-lg shadow flex flex-col space-y-3 transition-all duration-1000 cursor-pointer + ${ + animationPhase === "none" + ? "border-base-300 bg-base-100 text-base-content opacity-100" + : animationPhase === "bounce" + ? "border-primary bg-primary text-primary-content opacity-100" + : "border-primary bg-primary text-primary-content opacity-0" + } + `; + + return ( +
+
+

{shipment.shipment_reference}

+
+ {shipment.items.map((item) => ( +
+

+ {item.quantity}× {item.name} +

+
+

{item.model_number}

+

+ {(item.price / item.quantity).toFixed(2)}{" "} + {shipment.currency} +

+
+
+ {Object.entries(item.stockData).map( + ([stockName, stockArray]) => + stockArray.map((stock, idx) => ( +
+

{stockName}

+

Sklad: {stock.location}

+

{stock.count} ks

+
+ )) + )} +
+
+ ))} +
+
+ {animationPhase === "bounce" ? ( + + ) : ( + + )} +
+ ); + } +); + +// ------------------- ProcessedRow (for marking as unprocessed) ------------------- +interface ProcessedRowProps { + shipment: ShipmentRequest; + onUnprocess: (shipment: ShipmentRequest) => void; + onOpenDialog: (shipment: ShipmentRequest, type: "processed") => void; +} + +const ProcessedRow = forwardRef( + ({ shipment, onUnprocess, onOpenDialog }, ref) => { + const [animationPhase, setAnimationPhase] = useState< + "none" | "bounce" | "fade" + >("none"); + + useImperativeHandle(ref, () => ({ + triggerAnimation: () => { + setAnimationPhase("bounce"); + setTimeout(() => { + setAnimationPhase("fade"); + }, 1000); + setTimeout(() => { + onUnprocess(shipment); + }, 2000); + }, + })); + + const handleRowClick = () => { + onOpenDialog(shipment, "processed"); + }; + + const handleUnprocess = (e: React.MouseEvent) => { + e.stopPropagation(); + setAnimationPhase("bounce"); + setTimeout(() => { + setAnimationPhase("fade"); + }, 1000); + setTimeout(() => { + onUnprocess(shipment); + }, 2000); + }; + + /* Determine classes based on the animation phase: + - "none": border-base-300, bg-base-100, text-base-content + - "bounce": border-error, bg-error, text-error-content + - "fade": border-error, bg-error, text-error-content, opacity-0 + */ + const containerClasses = ` + p-4 border rounded-lg shadow flex flex-col space-y-3 transition-all duration-1000 cursor-pointer + ${ + animationPhase === "none" + ? "border-base-300 bg-base-100 text-base-content opacity-100" + : animationPhase === "bounce" + ? "border-error bg-error text-error-content opacity-100" + : "border-error bg-error text-error-content opacity-0" + } + `; + + return ( +
+
+

{shipment.shipment_reference}

+
+ {shipment.items.map((item) => ( +
+

+ {item.quantity}× {item.name} +

+
+

{item.model_number}

+

+ {(item.price / item.quantity).toFixed(2)}{" "} + {shipment.currency} +

+
+
+ {Object.entries(item.stockData).map( + ([stockName, stockArray]) => + stockArray.map((stock, idx) => ( +
+

{stockName}

+

Sklad: {stock.location}

+

{stock.count} ks

+
+ )) + )} +
+
+ ))} +
+
+ {animationPhase === "bounce" ? ( + + ) : ( + + )} +
+ ); + } +); + +type InertiaProps = { + auth: any; // adjust per your auth type + selectedBatchID: number; +}; + +// ------------------- Main Component ------------------- +export default function WarehouseExpedicePage() { + const { selectedBatchID } = usePage().props; + + // States for shipments + const [parcels, setParcels] = useState([]); + const [parcelsOther, setParcelsOther] = useState([]); + const [processed, setProcessed] = useState([]); + const [loading, setLoading] = useState(true); + + // Refs to control individual row animations + const parcelRowRefs = useRef<{ [key: number]: RowHandle | null }>({}); + const processedRowRefs = useRef<{ [key: number]: RowHandle | null }>({}); + + // Dialog state + const [selectedShipment, setSelectedShipment] = + useState(null); + const [selectedType, setSelectedType] = useState< + "parcels" | "processed" | null + >(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const handleCopyToClipboard = () => { + const copyString = parcels.map((p) => p.shipment_reference).join(","); + const copyStringOther = parcelsOther.map((p) => p.shipment_reference).join( + "," + ); + const copyStringProcessed = processed + .map((p) => p.shipment_reference) + .join(","); + + navigator.clipboard + .writeText([copyString, copyStringOther, copyStringProcessed].filter(Boolean).join(",")) + .then(() => { + alert("Parcels copied to clipboard!"); + }) + .catch((err) => { + console.error("Failed to copy:", err); + }); + }; + + useEffect(() => { + axios + .post("/api/expediceListWMS", { batch_id: selectedBatchID }) + .then((response) => { + setParcels(response.data.batch_items.shipments); + setParcelsOther(response.data.batch_items.shipments_other); + setLoading(false); + }) + .catch((error) => { + console.error("Error fetching data:", error); + setLoading(false); + }); + }, [selectedBatchID]); + + // Move a shipment from Parcels to Processed + const markAsProcessed = (shipment: ShipmentRequest) => { + setParcels((prev) => prev.filter((s) => s.id !== shipment.id)); + setProcessed((prev) => [...prev, shipment]); + }; + + // Move a shipment from Processed back to Parcels + const markAsUnprocessed = (shipment: ShipmentRequest) => { + setProcessed((prev) => prev.filter((s) => s.id !== shipment.id)); + setParcels((prev) => [...prev, shipment]); + }; + + // Open the dialog for a shipment + const openDialog = ( + shipment: ShipmentRequest, + type: "parcels" | "processed" + ) => { + setSelectedShipment(shipment); + setSelectedType(type); + setIsDialogOpen(true); + }; + + // Close the dialog + const closeDialog = () => { + setIsDialogOpen(false); + setSelectedShipment(null); + setSelectedType(null); + }; + + const pdaDialogRef = useRef(null); + const openPdaModal = () => { + pdaDialogRef.current?.showModal(); + }; + const closePdaModal = () => { + pdaDialogRef.current?.close(); + }; + + const [buttonStates, setButtonStates] = useState<{ [itemId: number]: any }>( + {} + ); + + // Handle processing/unprocessing via the dialog action button + const handleDialogProcess = () => { + if (selectedShipment && selectedType === "parcels") { + const filteredButtonStates = selectedShipment.items.reduce( + (acc: any, item: Item) => { + if (buttonStates[item.id]) { + acc[item.id] = buttonStates[item.id]; + } + return acc; + }, + {} + ); + + axios + .post("/api/warehouseExpedice/passOptions", { + shipment_request_id: selectedShipment.id, + buttonStates: filteredButtonStates, + shipment_items: selectedShipment.items, + }) + .catch((error) => { + console.error("Error sending button states:", error); + }); + + if (parcelRowRefs.current[selectedShipment.id]) { + parcelRowRefs.current[selectedShipment.id]?.triggerAnimation(); + } else { + markAsProcessed(selectedShipment); + } + } else if (selectedShipment && selectedType === "processed") { + if (processedRowRefs.current[selectedShipment.id]) { + processedRowRefs.current[selectedShipment.id]?.triggerAnimation(); + } else { + markAsUnprocessed(selectedShipment); + } + } + closeDialog(); + }; + + if (loading) { + return
Loading...
; + } + + return ( + <> + {/* Top bar */} +
+ + Back + +
+ {/* Open the modal using document.getElementById('ID').showModal() method */} + + {/* PDA VIEW DIALOG */} + +
+ +
+
+ +
+
+ {/* Tabs */} +
+ {/* Parcels Tab */} + +
+
+ {parcels.length === 0 ? ( +

No parcels available.

+ ) : ( + parcels.map((shipment) => ( + { + parcelRowRefs.current[shipment.id] = el; + }} + key={shipment.id} + shipment={shipment} + onProcess={markAsProcessed} + onOpenDialog={openDialog} + /> + )) + )} +
+
+ + {/* Nahravacky Tab */} + +
+
+ {parcelsOther.length === 0 ? ( +

No parcels available.

+ ) : ( + parcelsOther.map((shipment) => ( + { + parcelRowRefs.current[shipment.id] = el; + }} + key={shipment.id} + shipment={shipment} + onProcess={markAsProcessed} + onOpenDialog={openDialog} + /> + )) + )} +
+
+ + {/* Processed Tab (15% width) */} + +
+
+ {processed.length === 0 ? ( +

No processed shipments.

+ ) : ( + processed.map((shipment) => ( + { + processedRowRefs.current[shipment.id] = el; + }} + key={shipment.id} + shipment={shipment} + onUnprocess={markAsUnprocessed} + onOpenDialog={openDialog} + /> + )) + )} +
+
+
+ + {/* Shipment Details Dialog */} + {selectedShipment && ( + + )} + + ); +} diff --git a/resources/js/Pages/PdaView.tsx b/resources/js/Pages/PdaView.tsx index e0a0e76..0717669 100644 --- a/resources/js/Pages/PdaView.tsx +++ b/resources/js/Pages/PdaView.tsx @@ -1,16 +1,16 @@ -import React from 'react' +import React, {useEffect, useState} from 'react' import axios from 'axios' -import { Head, usePage } from '@inertiajs/react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import {Head, usePage} from '@inertiajs/react' +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' import { faArrowLeft, faQuestionCircle, faBoxOpen, faClipboardList, faCubes, - faPlus, + faPlus, faBarcode, } from '@fortawesome/free-solid-svg-icons' -import { toast, Toaster } from 'react-hot-toast' +import {toast, Toaster} from 'react-hot-toast' import Tile from '../Components/Tile' import TileLayout from '../Components/TileLayout' @@ -20,6 +20,16 @@ import ModalPDA from '../Components/ModalPDA' import SetStockModal from '../Components/modals/SetStockModal' import OtherReplacementModal from '../Components/modals/OtherReplacementModal' import CountStockModal from '../Components/modals/CountStockModal' +import {Batch} from "@/interfaces/interfaces"; +import {StockBatch, StockPosition, StockSection} from "@/types"; +import BatchInfoModal from "@/Components/modals/BatchInfoModal"; +import {downloadBatchBarcode} from "@/functions/functions" +import EditStockSections from "@/Components/modals/EditStockSections"; +import MoveSectionModal from "@/Components/modals/MoveSectionModal"; +import ChangeCountModal from "@/Components/modals/ChangeCountModal"; + +import error_scanner_sound from "@/sounds/error_scanner.mp3"; +import SetStockModalFromTemp from "@/Components/modals/SetStockModalFromTemp"; type Role = 'Expedice' | 'Skladnik' @@ -38,62 +48,103 @@ type TileConfig = { 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') - } + +type ModalKey = + 'setStock' + | 'setStockFromTemp' + | 'otherReplacement' + | 'countStock' + | 'batchInfo' + | 'editStock' + | 'moveStock' + | 'changeCount' + | null + +type PdaViewProps = { closeParent: () => void }; + +export default function PdaView({closeParent}: PdaViewProps) { + + 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') }, - ] - }, -} + {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, modalKey: 'batchInfo'}, + {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: 'Tisk QR kod na krabice', + icon: faClipboardList, + onClick: () => downloadBatchBarcode(selectedBatch?.id) + }, + ], + stockScan: [ + {title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked')}, + {title: 'Set Stock', icon: faCubes, modalKey: 'setStockFromTemp'}, + { + title: 'Zmena skladoveho mista (i presun jen casti kusu)', + icon: faClipboardList, + modalKey: 'moveStock' + }, + {title: 'Uprava skladoveho mista (mene sekci, apod)', icon: faClipboardList, modalKey: 'editStock'}, + {title: 'Zmena poctu', icon: faClipboardList, modalKey: 'changeCount'}, + { + 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 }, + auth: {user}, } = usePage<{ auth: { user: { role: string } } }>().props const [role, setRole] = React.useState( @@ -106,19 +157,260 @@ export default function PdaView() { // const isAdmin = user.role === 'admin' const tabs: Role[] = ['Expedice', 'Skladnik'] + const [selectedSerial, setSelectedSerial] = useState(null); + + const selectedSectionRef = React.useRef(null) + const selectedPositionRef = React.useRef(null) + const closeModal = () => setActiveModal(null) + // const closeModal = () => { + // setActiveModal(null) + // setPrevPosition(null) + // setPrevSection(null) + // setSelectedPosition(null) + // setSelectedSection(null) + // } + + const wrapOnClick = (orig?: () => void) => { + return () => { + if (orig) orig(); + // after the action, close the PDA modal: + closeParent(); + }; + }; + + const [selectedBatch, setSelectedBatch] = React.useState(); + const [selectedPosition, setSelectedPosition] = React.useState(); + const [selectedSection, setSelectedSection] = React.useState(); + + const [prevPosition, setPrevPosition] = useState(null) + const [prevSection, setPrevSection] = useState(null) + + useEffect(() => { + // fetchBatches(); + // fetchPosition(); + initScanner(); + }, []); + + const initScanner = () => { + // run once on mount + (async () => { + // see if we already have permission for any ports + const ports = await navigator.serial.getPorts(); + console.log("ports", ports); + if (ports.length > 0) { + // pick the first (or filter by vendorId/productId) + const port = ports[0]; + setSelectedSerial(port); + // now you can immediately open it: + connectToScanner(port); + } + })(); + }; + + const handlePortRequest = async () => { + try { + const port = await navigator.serial.requestPort(); // <-- user gesture! + setSelectedSerial(port); + await connectToScanner(port); + } catch (err) { + console.error("User cancelled or error:", err); + } + }; + + + const connectToScanner = async (port) => { + try { + // Request a port and open a connection + console.log(selectedSerial); + await port.open({baudRate: 9600}); + + // setIsConnected(true); + + const reader = port.readable.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; // Buffer to accumulate chunks + + while (true) { + const {value, done} = await reader.read(); + if (done) { + // Close the stream + reader.releaseLock(); + break; + } + if (value) { + // Decode and append to the buffer + buffer += decoder.decode(value, {stream: true}); + // Process messages split by newline or other delimiter + let lines = buffer.split(/[\n\t]/); + buffer = lines.pop(); // Keep the last incomplete chunk + + for (const line of lines) { + const trimmedLine = line.trim(); + sendToBackend(trimmedLine); + } + } + } + } catch (error) { + console.error("Error connecting to scanner:", error); + } + }; + + + const sendToBackend = async (scannedData: string) => { + const audioError = document.getElementById("audio_error") as HTMLAudioElement; + audioError.volume = 1.0; + + console.log("scannedData", scannedData); + + // 1) Parse JSON + let payload: { barcode_type: string; payload: { id: number } }; + try { + payload = JSON.parse(scannedData); + } catch (err) { + toast.error("Invalid barcode data"); + audioError.play(); + return; + } + + const {barcode_type, payload: inner} = payload; + const {id} = inner; + + // 2) Call your API + try { + const response = await axios.post("/api/pda/barcodeScan", { + barcode_type, + payload: {id}, + }); + + const {success, data, message, error} = response.data; + + console.log("response", data); + + if (!success) { + // Laravel sends 200+ success=false for business errors, or 4xx/5xx for validation/not-found/etc + toast.error( + // Validation errors come back as an object of messages + Array.isArray(message) + ? Object.values(message).flat().join("; ") + : message || "Scan failed" + ); + audioError.play(); + return; + } + + // 3) On success, data will be the model (batch/section/position/label) or undefined for carrier_scanned + switch (barcode_type) { + case "stock_batch": + setAction("batchScan"); + setSelectedBatch(data); + // you now have the full batch record in `data` + break; + + case "stock_section": + setAction("stockScan"); + + // the “from” section is whatever was last in the ref + const oldSection = selectedSectionRef.current + // const oldPosition = selectedPositionRef.current + + // the “to” section is the data you just fetched + const newSection = data + + setPrevSection(oldSection) + // setPrevPosition(oldPosition) + + setSelectedSection(newSection) + // setSelectedPosition(newSection.position) + + console.log("FROM (prevSection):", oldSection) + console.log(" TO (selectedSection):", newSection) + break + + case "stock_position": + + setPrevPosition(selectedPosition || null); + + setAction("stockScan"); + setSelectedPosition(data); + break; + + case "label_scanned": + setAction("labelScan"); + // perhaps `data.product` or `data.batch` are in here if you need them + break; + + case "carrier_scanned": + setAction("carrierScan"); + // no data field returned, but success=true means it worked + break; + + default: + // validator already prevents this, but just in case + toast.error(`Unknown barcode type: ${barcode_type}`); + audioError.play(); + } + } catch (err: any) { + // 4xx / 5xx HTTP errors, or network failures + if (err.response) { + // Laravel ValidationException → 422 + if (err.response.status === 422) { + const errors = err.response.data.message; + toast.error( + Array.isArray(errors) + ? errors.join("; ") + : typeof errors === "object" + ? Object.values(errors).flat().join("; ") + : errors + ); + } + // ModelNotFound → 404 + else if (err.response.status === 404) { + toast.error(err.response.data.message || "Record not found"); + } + // Any other server error + else { + toast.error(err.response.data.message || "Server error"); + } + } else { + // network / CORS / timeout + toast.error("Network error"); + } + console.error("sendToBackend error:", err); + audioError.play(); + } + }; + + useEffect(() => { + selectedSectionRef.current = selectedSection ?? null + }, [selectedSection]) + + useEffect(() => { + selectedPositionRef.current = selectedPosition ?? null + }, [selectedPosition]) + + return ( <> - + + {/* Top bar */}
- Back + Back + {!selectedSerial && ( + + )} +
{/* Admin tabs */} @@ -158,8 +450,17 @@ export default function PdaView() { {/* Tiles */} - {action && - (tilesConfig[role][action] || []).map(({ title, icon, onClick, modalKey }) => ( + {action && (() => { + // grab the base array + let tiles = tilesConfig[role][action] || [] + + // if we're stock-scanning but the position isn’t temporary, hide “Set Stock” + if (action === 'stockScan' && !selectedPosition?.temporary) { + tiles = tiles.filter(tile => tile.modalKey !== 'setStockFromTemp') + } + + // render the filtered list + return tiles.map(({ title, icon, onClick, modalKey }) => ( - ))} + )) + })()} {/* Single Modal */} - {activeModal === 'setStock' && } - {activeModal === 'otherReplacement' && } - {activeModal === 'countStock' && } + {activeModal === 'batchInfo' && } + {activeModal === 'setStock' && + } + {activeModal === 'setStockFromTemp' && + } + {activeModal === 'otherReplacement' && } + {activeModal === 'countStock' && } + {activeModal === 'editStock' && + } + {activeModal === 'moveStock' && + + } + {activeModal === 'changeCount' && + } - + ) } diff --git a/resources/js/Pages/ShippingBatchList.tsx b/resources/js/Pages/ShippingBatchList.tsx new file mode 100644 index 0000000..62359dd --- /dev/null +++ b/resources/js/Pages/ShippingBatchList.tsx @@ -0,0 +1,94 @@ +import React, {useEffect, useState} from "react"; +import axios from "axios"; +import {Head} from "@inertiajs/react"; +import {Toaster} from "react-hot-toast"; +import AppLayout from "@/Layouts/AppLayout"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faCircleXmark, faSquareCheck} from "@fortawesome/free-solid-svg-icons"; + +export default function ShippingBatchList() { + const [batches, setBatches] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + axios + .get("/api/batchListWMS") + .then((response) => { + console.log(response.data.batches); + setBatches(response.data.batches); + setLoading(false); + }) + .catch((error) => { + console.error("Error fetching batches:", error); + setLoading(false); + }); + }, []); + + return ( + ( +

Expedice batches

+ )} + > + + + +
+
+ + {loading ? ( +
Loading...
+ ) : ( +
+ + + + + + + + + + + {batches.filter(b => b.item_count > 0).map((batch) => ( + window.location.href = `/expedice/${batch.id}`}> + + + + + + ))} + +
IDCarrierResultsCreated At
{batch.id} +
+
+
+ +
+
+
+ {batch.carrier_master.shortname} +
+
+
+
+
+ + {batch.item_count} +
+ +
+ + {batch.error_count} +
+
+
{batch.created_at}
+
+ )} +
+
+
+ ); +} diff --git a/resources/js/Pages/StockBatch.tsx b/resources/js/Pages/StockBatch.tsx index 5ae9be5..6e9ca92 100644 --- a/resources/js/Pages/StockBatch.tsx +++ b/resources/js/Pages/StockBatch.tsx @@ -1,3 +1,5 @@ +// StockBatches.tsx + import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Head } from '@inertiajs/react'; import AppLayout from '@/Layouts/AppLayout'; @@ -11,12 +13,21 @@ import { } 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' } +import { faCheck, faQrcode, faListOl } from '@fortawesome/free-solid-svg-icons'; +import {PhysicalItem, StockBatch, StockSection, Supplier} from '@/types'; +import { size } from 'lodash'; +import { router } from '@inertiajs/react'; +import BatchInfoWindow from '@/Components/BatchInfoWindow'; + +interface DropdownOption { + id: number; + name: string; +} + +interface FileWithType { + file: File; + fileType: 'invoice' | 'label' | 'other'; +} interface StockEntry { id: number; @@ -30,11 +41,14 @@ interface StockEntry { stock_position_id: number | null; country_of_origin_id: number | null; on_the_way: boolean; + surplus_item: boolean; stock_batch_id: number; - physical_item?: DropdownOption; + physical_item?: PhysicalItem; 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 } })[]; + 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: '' }; @@ -54,7 +68,7 @@ const defaultEntryForm: Omit = { 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); @@ -78,7 +92,10 @@ export default function StockBatches() { const [onTheWayEntries, setOnTheWayEntries] = useState([]); const [entriesOnTheWayLoading, setEntriesOnTheWayLoading] = useState(false); const [onTheWayEntriesCount, setOnTheWayEntriesCount] = useState(0); - const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); const [onTheWayEntriesSorting, setOnTheWayEntriesSorting] = useState([{ id: 'id', desc: false }]); const [onTheWayEntriesFilter, setOnTheWayEntriesFilter] = useState(''); @@ -88,13 +105,16 @@ export default function StockBatches() { const [editingEntry, setEditingEntry] = useState(null); const [entryForm, setEntryForm] = useState>({ ...defaultEntryForm }); - const [physicalItems, setPhysicalItems] = useState([]); + 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 + : physicalItems.filter((i) => i.name.toLowerCase().includes(itemQuery.toLowerCase())), [itemQuery, physicalItems] ); @@ -104,10 +124,14 @@ export default function StockBatches() { 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); + // 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 }); @@ -127,35 +151,30 @@ export default function StockBatches() { 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[]>( + const batchColumns = 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: '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()), }, - { accessorKey: 'updated_at', header: 'Last Updated', size: 150 }, ], - [], + [] ); const entryOnTheWayColumns = useMemo[]>( @@ -169,9 +188,11 @@ export default function StockBatches() { setOnTheWayEntriesSelections(prev => - prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] - )} + onChange={() => + setOnTheWayEntriesSelections((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ) + } /> ); }, @@ -179,16 +200,28 @@ export default function StockBatches() { { accessorKey: 'id', header: 'ID' }, { accessorKey: 'physical_item.name', header: 'Item' }, { accessorKey: 'supplier.name', header: 'Supplier' }, - ], [onTheWayEntriesSelections] + ], + [onTheWayEntriesSelections] ); - useEffect(() => { fetchOptions(); fetchEntriesOnTheWay(); fetchStatusList();}, []); - useEffect(() => { fetchBatches(); }, [batchPagination, batchSorting, batchFilter]); - useEffect(() => { if (selectedBatch) fetchEntries(selectedBatch.id); }, [entriesPagination, entriesSorting, entriesFilter]); + useEffect(() => { + fetchOptions(); + fetchEntriesOnTheWay(); + }, []); + 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'); } + try { + const res = await axios.get('/api/stockBatches/options'); + setSuppliers(res.data.suppliers); + } catch { + toast.error('Cannot load suppliers'); + } } async function fetchOptions() { @@ -196,45 +229,87 @@ export default function StockBatches() { 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'); } + } 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); } + 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); } + 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, + }, + }); + 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); } + 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 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 openView = (batch: StockBatch) => { + setSelectedBatch(batch); + fetchEntries(batch.id); + viewDialogRef.current?.showModal(); + }; + const closeView = () => { + viewDialogRef.current?.close(); + setEntries([]); + setSelectedBatch(null); + }; const openEntry = (entry?: StockEntry) => { fetchEntriesOnTheWay(); @@ -244,43 +319,45 @@ export default function StockBatches() { 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 + const rows = + entry.sections?.map((sec) => ({ + stock_position_id: sec.pivot.section_id, + count: sec.pivot.count, + })) || []; 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 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 })); + 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' }))); + 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)); + setBatchFiles((prev) => prev.map((f, i) => (i === idx ? { ...f, fileType: type } : f))); }; const removeBatchFile = (idx: number) => { - setBatchFiles(prev => prev.filter((_, i) => i !== idx)); + setBatchFiles((prev) => prev.filter((_, i) => i !== idx)); }; const handleBatchSubmit = async (e: React.FormEvent) => { @@ -290,12 +367,21 @@ export default function StockBatches() { 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(); + batchFiles.forEach(({ file, fileType }) => { + fd.append('files[]', file); // note the [] + fd.append('file_types[]', fileType); // note the [] + }); + // await axios.post('/api/stockBatches', fd, { headers: { 'Content-Type': 'multipart/form-data' } }); + await axios.post('/api/stockBatches', fd); + + 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)); + Object.values(err.response.data.errors) + .flat() + .forEach((m: string) => toast.error(m)); } else { toast.error('Failed to create batch'); } @@ -304,9 +390,8 @@ export default function StockBatches() { const handleBatchAddEntries = async (e: React.FormEvent) => { e.preventDefault(); - console.log(selectedBatch); try { - await axios.put(`/api/stockBatches/${selectedBatch.id}/entries`, { ids: onTheWayEntriesSelections }); + await axios.put(`/api/stockBatches/${selectedBatch!.id}/entries`, { ids: onTheWayEntriesSelections }); toast.success('Batch entries updated successfully'); closeEntry(); fetchBatches(); @@ -320,57 +405,33 @@ export default function StockBatches() { const handleEntryInputChange = (e: React.ChangeEvent) => { const { name, value, type, checked } = e.target; - setEntryForm(prev => ({ + 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); + if (editingEntry) { + await axios.put(`/api/stockData/${editingEntry.id}`, { + entryForm, + sections: positionRows.filter((r) => r.stock_position_id && r.count), + }); + } + else { + entryForm.supplier_id = selectedBatch?.supplier_id || null; + await axios.post(`/api/stockData`, entryForm); + } toast.success(editingEntry ? 'Entry updated' : 'Entry created'); - fetchEntries(selectedBatch!.id); closeEntry(); - } catch { toast.error('Cannot save entry'); } + 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 { @@ -380,317 +441,445 @@ export default function StockBatches() { 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

}> + {/* ===== Main Batches Table ===== */}

Batches

- +
({ onClick: () => openView(row.original), style: { cursor: 'pointer' } })} + state={{ + isLoading: batchLoading, + pagination: batchPagination, + sorting: batchSorting, + globalFilter: batchFilter, + }} + muiTableBodyRowProps={({ row }) => ({ + onClick: () => openView(row.original), + style: { cursor: 'pointer' }, + })} />
+ {/* ===== Create New Batch Dialog ===== */}
- +

New Stock Batch

- - - {suppliers.map(s => )} + {suppliers.map((s) => ( + + ))}
- - + +
- - + +
- - + +
{batchFiles.map((f, i) => (
{f.file.name} - handleBatchFileTypeChange(i, e.target.value as any)} + className="select select-sm" + > + + + - +
))}
- +
+ {/* ===== View Batch Details Dialog ===== */} -
- -
+
+ + {/* Replace inline batch‐details/table with */} + {selectedBatch && ( + route(name, params)} + entries={entries} + openEntry={openEntry} + recountEnabled={true} + /> + )} + +
+
-

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' }})} - /> -
-
-
- -
-
- -
- +
+ {/* ===== Entry Dialog (unchanged) ===== */}
- + - {!editingEntry && + {!editingEntry && (

Select Incoming Items

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

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

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