wip warehouse and inc stock workflow

This commit is contained in:
t0is 2025-06-20 14:29:51 +02:00
parent e637d26842
commit 6768f8d5b7
50 changed files with 6861 additions and 518 deletions

4
.env
View File

@ -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

View File

@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockEntry;
use App\Models\StockEntryStatusHistory;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ExpediceController extends Controller
{
public function batchListWMS(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' => 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);
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockPosition;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Log;
// Replace these with your actual model classes
use App\Models\StockBatch;
use App\Models\StockSection;
use Metzli\Encoder\Encoder;
use Metzli\Renderer\PngRenderer;
class ScannerController extends Controller {
public function barcodeScan(Request $request)
{
// 1) Validate the incoming JSON structure
// {"barcode_type": "stock_batch","payload":{"id": 7}}
try {
$data = $request->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 checkedin.
// 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);
}
}

View File

@ -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');
@ -68,7 +69,9 @@ class StockBatchController extends Controller
'supplier_id' => 'nullable|integer',
'tracking_number' => 'nullable|string',
'arrival_date' => 'nullable|date',
'files.*' => 'file',
'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',
]);

View File

@ -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);
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockLine;
use Illuminate\Http\Request;
class StockLineController extends Controller
{
public function store(Request $request)
{
$data = $request->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);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockPosition;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class StockPositionController extends Controller
{
/**
* Update the capacity of a given StockPosition.
*
* Request payload:
* {
* "capacity": <integer> // 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: "<code>", message: "<details>" } 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) Eagerload 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 toplevel "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);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockRack;
use Illuminate\Http\Request;
class StockRackController extends Controller
{
public function store(Request $request)
{
$data = $request->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);
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\StockRoom;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class StockRoomController extends Controller
{
public function store(Request $request)
{
$data = $request->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, lets 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']);
}
}

View File

@ -0,0 +1,711 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockBatch;
use App\Models\StockEntries2Section;
use App\Models\StockEntry;
use App\Models\StockPosition;
use App\Models\StockSection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class StockSectionController extends Controller
{
public function getSection(Request $request, int $id)
{
try {
return response()->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 <= sections 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 <= sections 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 <= sections 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": <string>,
* "capacity": <integer 0>,
* "retrievable": <boolean>
* }
*
* 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);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\StockShelf;
use Illuminate\Http\Request;
class StockShelfController extends Controller
{
public function store(Request $request)
{
$data = $request->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);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use App\Models\StockRoom;
use App\Models\StockLine;
use App\Models\StockRack;
use App\Models\StockShelf;
use App\Models\StockPosition;
use App\Models\StockSection;
class StorageController extends Controller
{
/**
* Handle initial bulkcreation of a new storage layout.
*
* Expected JSON payload:
* {
* "room_symbol": "RM1",
* "room_name": "Main Storeroom",
* "num_lines": 5, // minimum 1
* "racks_per_line": 5, // minimum 1
* "shelves_per_rack": 5, // minimum 1
* "positions_per_shelf": 4, // minimum 1
* "sections_per_position": 1 // minimum 1
* }
*/
public function setup(Request $request)
{
$data = $request->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);
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PhysicalItemType extends Model
{
use HasFactory;
// Use the same connection as your other warehouse models
protected $connection = 'vat_warehouse';
protected $table = 'physical_item_type';
// Allow mass assignment on these fields
protected $fillable = [
'name',
'code_suffix',
'priority',
'weight',
'volume',
'title_template_id',
'description_template_id',
'created_by',
'updated_by',
];
/**
* Get all physical items of this type.
*/
public function items(): HasMany
{
return $this->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');
}
}

View File

@ -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');
}
}

View File

@ -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;

View File

@ -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
);
}
}

View File

@ -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
);
}
}

View File

@ -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",

198
composer.lock generated
View File

@ -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": [

View File

@ -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'
];

View File

@ -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)
);

263
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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';

View File

@ -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, any>) => 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<StockEntry>[]
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<Status[]>([])
/** 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<MRT_ColumnDef<StockEntry>[]>(
() => [
{ 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<number>()?.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 (
<div className="flex sm:flex-row flex-col space-y-4 sm:space-y-0 sm:space-x-4">
{/* Left: Batch Details & Ratios */}
<div className="sm:w-1/3 flex flex-col justify-between">
<div>
<h3 className="font-bold text-lg mb-2">Batch Details</h3>
{selectedBatch ? (
<ul className="list-disc list-inside">
<li>
<strong>ID:</strong> {selectedBatch.id}
</li>
<li>
<strong>Supplier:</strong> {selectedBatch.supplier.name}
</li>
<li>
<strong>Tracking #:</strong> {selectedBatch.tracking_number}
</li>
<li>
<strong>Arrival:</strong>{' '}
{formatDate(selectedBatch.arrival_date, false)}
</li>
</ul>
) : (
<p className="text-sm text-gray-500">No batch selected.</p>
)}
{selectedBatch && (
<div style={{ marginTop: 24 }}>
<h3 className="font-semibold text-md mb-2">Status Ratios</h3>
{statusIds.map((id) => {
const { count, total, ratio } = calculateStatusRatio(
selectedBatch.stock_entries,
id
)
const statusData = statuses.find((s) => s.id === id)
return (
<p key={id} className="text-sm">
{statusData?.name}: {count} / {total} (
{(ratio * 100).toFixed(1)}%)
</p>
)
})}
</div>
)}
</div>
<div className="mt-4 flex space-x-2">
<div className="tooltip" data-tip="Batch QR kód">
<button className="btn bg-[#666666] text-[19px]" onClick={() => downloadBatchBarcode(selectedBatch?.id)}>
<FontAwesomeIcon icon={faQrcode} />
</button>
</div>
{hasNonCountedStatus && selectedBatch && recountEnabled && (
<div className="tooltip" data-tip="Přepočítat">
<button
className="btn btn-warning text-[19px]"
onClick={() =>
router.get(
route('batchCounting', { selectedBatch: selectedBatch.id })
)
}
>
<FontAwesomeIcon icon={faListOl} />
</button>
</div>
)}
</div>
</div>
{/* Right: Stock Entries Table (never pass null!) */}
<div className="sm:w-2/3 flex flex-col sm:pl-6">
<h3 className="font-bold text-lg mb-2">Stock Entries</h3>
<div className="flex-1 overflow-auto">
<MaterialReactTable
columns={entryColumnsMemo}
data={selectedBatch?.stock_entries} // ← guaranteed array, never null
manualPagination
manualSorting
enableGlobalFilter
initialState={{ pagination: { pageSize: 100 } }}
muiTableBodyRowProps={({ row }) => ({
onClick: () => openEntry?.(row.original),
style: { cursor: 'pointer' },
})}
/>
</div>
</div>
</div>
)
}

View File

@ -12,13 +12,14 @@ const ModalPDA: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
return (
<div className="modal modal-open">
<div className="modal-box relative">
<div className="modal-box max-w-6xl flex flex-col space-x-6 p-6 relative">
<button
className="btn btn-sm btn-circle absolute right-2 top-2"
onClick={onClose}
>
</button>
{children}
</div>
</div>

View File

@ -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<React.SetStateAction<{ [itemId: number]: any }>>;
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<number | null>(
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<number | null>(null);
// Modal state
const [modalPositionOpen, setModalPositionOpen] = useState(false);
const [currentItem, setCurrentItem] = useState<any>(null);
const [fromPosition, setFromPosition] = useState<string>("");
const [toPosition, setToPosition] = useState<string>("");
// modalMode: "full" or "select-only"
const [modalMode, setModalMode] = useState<"full" | "select-only">("full");
// modalKey: "neniNaPozici" | "doskladnit" | "zmenitPozici"
const [modalKey, setModalKey] = useState<string | null>(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 (
<div className="space-y-4">
{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 (
<div key={item.id} className="space-y-2">
<div
className={`collapse collapse-arrow border border-t-2 border-warning border-b-0 border-l-0 border-r-0 bg-base-100 ${headerOpacity}`}
>
<input
type="checkbox"
checked={isOpen}
onChange={() => handleToggle(item.id)}
className="peer"
/>
<div
className="collapse-title flex justify-between items-center text-base-content px-4 py-2 cursor-pointer"
onDoubleClick={() => handleComplete(item.id)}
onTouchStart={() => onTouchStartHandler(item.id)}
onTouchEnd={onTouchEndHandler}
>
<div>
<p className="font-semibold text-base-content">
{item.quantity}× {item.name}
</p>
<p className="text-sm text-base-content">{item.item_note}</p>
<div className="flex justify-between text-sm text-base-content mt-1">
<span>{item.model_number}</span>
<span>
{(item.price / item.quantity).toFixed(2)} {selectedShipment.currency}
</span>
</div>
</div>
{isCompleted && (
<FontAwesomeIcon icon={faCheckCircle} className="text-success" />
)}
</div>
<div className="collapse-content bg-base-200 text-base-content p-4">
<div className="flex flex-wrap gap-4 border border-base-300 rounded bg-base-100 p-3">
{Object.entries(item.stockData).flatMap(([stockName, stockArray]) =>
stockArray.map((stock, idx) => (
<div key={`${stockName}-${idx}`} className="text-sm">
<p className="font-semibold">{stockName}</p>
<p>Location: {stock.location}</p>
<p>Count: {stock.count} ks</p>
</div>
))
)}
</div>
</div>
</div>
<div className={`flex flex-wrap items-center gap-2 text-base-content ${headerOpacity}`}>
<button
onClick={() =>
handleOpenModal(item, "neniNaPozici", "select-only")
}
className={`btn btn-outline btn-sm ${!isOpen ? "pointer-events-none" : ""} ${itemState.neniNaPozici.value ? "btn-success" : ""} w-full sm:w-auto`}
>
<FontAwesomeIcon icon={faClipboardQuestion} className="mr-2" />
Není na pozici
</button>
<button
onClick={() =>
handleOpenModal(item, "doskladnit", "select-only")
}
className={`btn btn-outline btn-sm ${!isOpen ? "pointer-events-none" : ""} ${itemState.doskladnit.value ? "btn-success" : ""} w-full sm:w-auto`}
>
<FontAwesomeIcon icon={faBoxesStacked} className="mr-2" />
Doplnit
</button>
<button
onClick={() =>
handleOpenModal(item, "zmenitPozici", "full")
}
className={`btn btn-outline btn-sm ${!isOpen ? "pointer-events-none" : ""} ${itemState.zmenitPozici.value ? "btn-success" : ""} w-full sm:w-auto`}
>
<FontAwesomeIcon icon={faShuffle} className="mr-2" />
Změnit pozici
</button>
<button
onClick={() => handleComplete(item.id)}
className={`btn btn-outline btn-sm ml-auto ${buttonFunctionality} w-full sm:w-auto`}
>
<FontAwesomeIcon icon={faCheckToSlot} className="text-lg" />
</button>
</div>
</div>
);
})}
{/* Modal for changing position */}
<div className={`modal ${modalPositionOpen ? "modal-open" : ""}`}>
<div className="modal-box max-w-md">
<h3 className="font-bold text-lg">Change Position</h3>
<div className="space-y-4 mt-4">
<div>
<label className="block text-sm font-medium text-base-content mb-1">
From Position
</label>
<select
className="select select-bordered w-full"
value={fromPosition}
onChange={(e) => setFromPosition(e.target.value)}
>
<option value="">Select position</option>
{currentItem &&
Object.entries(currentItem.stockData).flatMap(
([stockName, stockArray]) =>
stockArray.map((stock, idx) => (
<option
key={`${stockName}-${idx}`}
value={`${stockName}--${stock.location}`}
>
<strong>{stockName}</strong> --- {stock.location}
</option>
))
)}
</select>
</div>
{modalMode === "full" && (
<div>
<label className="block text-sm font-medium text-base-content mb-1">
To Position
</label>
<input
type="text"
className="input input-bordered w-full"
value={toPosition}
onChange={(e) => setToPosition(e.target.value)}
placeholder="Enter new position"
/>
</div>
)}
</div>
<div className="modal-action mt-4 flex justify-end gap-2">
<button className="btn btn-primary btn-sm" onClick={handleConfirmModal}>
Confirm
</button>
<button
className="btn btn-outline btn-sm"
onClick={() => setModalPositionOpen(false)}
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<React.SetStateAction<{ [itemId: number]: any }>>;
}
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<Record<number, { url: string }>>({});
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 (
<div className={`modal ${isDialogOpen ? "modal-open" : ""}`}>
{/* Modal backdrop + box */}
<div className="modal-box max-w-4xl max-h-[90vh] overflow-hidden p-0">
{/* Header with Tabs and Close button */}
<div className="flex items-center justify-between border-b border-base-300 px-4 py-3">
<div className="tabs">
<a
className={`tab ${activeTab === "stock" ? "tab-active" : ""}`}
onClick={() => setActiveTab("stock")}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faCubesStacked} />
<span>Items</span>
</div>
</a>
<a
className={`tab ${activeTab === "details" ? "tab-active" : ""}`}
onClick={handleDetailsClick}
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleInfo} />
<span>Details</span>
</div>
</a>
</div>
<button
onClick={closeDialog}
className="btn btn-ghost btn-circle text-base-content hover:text-error"
>
<FontAwesomeIcon icon={faXmark} size="lg" />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto p-4 bg-base-100">
{activeTab === "details" && (
<div className="space-y-6">
{/* Reference */}
<div>
<h2 className="text-2xl font-semibold text-base-content">
Ref. # {selectedShipment.shipment_reference}
</h2>
</div>
{/* Items Section */}
<div className="bg-base-200 p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold text-base-content mb-2">Items</h3>
{selectedShipment.items.map((item) => (
<div key={item.id} className="collapse collapse-arrow border border-base-300 rounded-lg mb-4">
<input type="checkbox" className="peer" />
<div className="collapse-title flex items-center justify-between bg-base-100 text-base-content px-4 py-2">
<div className="flex items-center gap-4">
<a
href={imageArray[item.id]?.url || "#"}
target="_blank"
rel="noopener noreferrer"
className="w-24 h-36 bg-base-300 rounded overflow-hidden flex-shrink-0 flex items-center justify-center"
>
{imageArray[item.id]?.url ? (
<img src={imageArray[item.id].url} alt="Item" className="w-full h-full object-center" />
) : (
<div className="w-auto h-full bg-base-200 flex items-center justify-center text-sm text-base-content">
No Image
</div>
)}
</a>
<div>
<p className="font-bold">{item.quantity}× {item.name}</p>
</div>
</div>
<div className="text-sm text-base-content">
{(item.price / item.quantity).toFixed(2)} {selectedShipment.currency}
</div>
</div>
<div className="collapse-content bg-base-100 text-base-content p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="font-semibold">Model Number:</p>
<p>{item.model_number}</p>
</div>
<div>
<p className="font-semibold">Origin:</p>
<p>{item.originCountry}</p>
</div>
<div>
<p className="font-semibold">Weight:</p>
<p>{item.weight}</p>
</div>
</div>
{item.stockData && Object.keys(item.stockData).length > 0 && (
<div className="mt-4">
<p className="font-semibold mb-2">Stock Information:</p>
{Object.entries(item.stockData).flatMap(([stockName, stockArray]) =>
stockArray.map((stock, idx) => (
<div
key={`${stockName}-${idx}`}
className="border-b border-base-300 pb-2 mb-2 last:border-0"
>
<p className="font-bold">{stockName}</p>
<p>Location: {stock.location}</p>
<p>Count: {stock.count} ks</p>
</div>
))
)}
</div>
)}
</div>
</div>
))}
</div>
{/* Delivery Address */}
<div className="bg-base-200 p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold text-base-content mb-2">Delivery Address</h3>
<div className="space-y-1 text-base-content">
<p>
<span className="font-semibold">Name:</span> {selectedShipment.delivery_address_name}
</p>
{selectedShipment.delivery_address_company_name && (
<p>
<span className="font-semibold">Company:</span> {selectedShipment.delivery_address_company_name}
</p>
)}
<p>
<span className="font-semibold">Street:</span> {selectedShipment.delivery_address_street_name}{" "}
{selectedShipment.delivery_address_street_number}
</p>
<p>
<span className="font-semibold">City:</span> {selectedShipment.delivery_address_city}
</p>
<p>
<span className="font-semibold">ZIP:</span> {selectedShipment.delivery_address_zip}
</p>
{selectedShipment.delivery_address_state_iso && (
<p>
<span className="font-semibold">State:</span> {selectedShipment.delivery_address_state_iso}
</p>
)}
<p>
<span className="font-semibold">Country:</span> {selectedShipment.delivery_address_country_iso}
</p>
</div>
</div>
{/* Carrier & Shipment Info */}
<div className="bg-base-200 p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold text-base-content mb-2">Carrier & Shipment Info</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-base-content">
<div>
<p className="font-semibold">Carrier:</p>
<p>{selectedShipment.carrier.carrier_name}</p>
</div>
<div>
<p className="font-semibold">Tracking Number:</p>
<p>{selectedShipment.shipment?.tracking_number || ""}</p>
</div>
<div>
<p className="font-semibold">Shipment Price:</p>
<p>
{selectedShipment.currency} {selectedShipment.shipment_price}
</p>
</div>
<div>
<p className="font-semibold">Shipment Value:</p>
<p>{selectedShipment.shipment_value}</p>
</div>
<div>
<p className="font-semibold">Weight:</p>
<p>{selectedShipment.weight}</p>
</div>
<div>
<p className="font-semibold">Dimensions:</p>
<p>
{selectedShipment.length} × {selectedShipment.width} × {selectedShipment.height}
</p>
</div>
</div>
</div>
</div>
)}
{activeTab === "stock" && (
<div className="text-base-content">
<ShipmentItemsAccordion
selectedShipment={selectedShipment}
buttonStates={buttonStates}
setButtonStates={setButtonStates}
parentModalHandle={dialogToggleClose}
/>
</div>
)}
</div>
{/* Footer with action button */}
<div className="modal-action border-t border-base-300 p-4 flex justify-end gap-2">
{selectedType === "parcels" && (
<button className="btn btn-primary" onClick={handleDialogProcess}>
Mark as Processed
</button>
)}
{selectedType === "processed" && (
<button className="btn btn-error" onClick={handleDialogProcess}>
Mark as Unprocessed
</button>
)}
<button className="btn btn-ghost" onClick={closeDialog}>
Close
</button>
</div>
</div>
</div>
);
}

View File

@ -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<Props> = ({ onClose, selectedBatch }) => {
return (
<div className="space-y-4">
<h3 className="font-bold text-lg">Batch # {selectedBatch.id}</h3>
<BatchInfoWindow
selectedBatch={selectedBatch}
router={router}
route={(name, params) => route(name, params)}
entries={null}
openEntry={() => {}}
recountEnabled={false}
/>
</div>
)
}
export default BatchInfoModal

View File

@ -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<Props> = ({ onClose, selectedPosition }) => {
const [selectedEntry, setSelectedEntry] = React.useState<StockEntry | null>(null)
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
const [newCount, setNewCount] = React.useState<number | null>(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<StockEntry[]>(() => {
if (!selectedPosition.sections) return []
return selectedPosition.sections.flatMap((section) =>
section.entries.map((entry) => ({ ...entry, pivot: entry.pivot, _section: section }))
)
}, [selectedPosition])
console.log(entriesList);
return (
<div className="space-y-4">
<h3 className="font-bold text-lg">Change item count</h3>
{/* Entry Dropdown */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Select Item</span>
</label>
<div className="dropdown w-full">
<button
type="button"
className="btn w-full justify-between"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{selectedEntry ? (
<div className="flex items-center space-x-2">
<img
src={selectedEntry?.physical_item?.image_url}
alt={selectedEntry?.physical_item?.name}
className="w-6 h-6 rounded-full"
/>
<span>
{selectedEntry?.physical_item?.name} (Count: {selectedEntry?.pivot.count}) in Sect.{' '}
{entriesList.find(e => e.id === selectedEntry.id)._section.section_symbol}
</span>
</div>
) : (
'Select item...'
)}
<svg
className="fill-current w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M5.516 7.548a.75.75 0 0 1 1.06 0L10 10.972l3.424-3.424a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 0 1 0-1.06z"/>
</svg>
</button>
<ul
tabIndex={0}
className={`dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full mt-1 ${
isDropdownOpen ? 'block' : 'hidden'
}`}
>
{entriesList.map((entry) => (
<li key={`${entry.id}-${entry.pivot.section_id}`}>
<button
type="button"
className="flex items-center px-2 py-1 hover:bg-gray-100 rounded"
onClick={() => {
setSelectedEntry(entry)
setIsDropdownOpen(false)
}}
>
<img
src={entry.physical_item?.image_url}
alt={entry.physical_item?.name}
className="w-6 h-6 rounded-full mr-2"
/>
<span>
{entry.physical_item?.name} ({entry.pivot?.count} ks) Section {entry._section?.section_symbol}
</span>
</button>
</li>
))}
{entriesList.length === 0 && (
<li>
<span className="px-2 py-1 text-gray-500">No items in this position</span>
</li>
)}
</ul>
</div>
{selectedEntry ? (
<label className="input">
<input type="number" className="grow" placeholder="123" onChange={(e) => setNewCount(parseInt(e.target.value))} />
<kbd className="kbd kbd-sm">123</kbd>
</label>
): ""}
</div>
{/* Simulate Scan Button */}
<div className="flex space-x-2">
<button
type="button"
className="btn btn-primary"
onClick={handleChangeCount}
disabled={!selectedEntry || loading}
>
Confirm
</button>
</div>
{/* Waiting for scan indicator */}
{selectedEntry && newCount === null && !loading && (
<div className="flex items-center space-x-2 mt-2">
<span>Waiting for section scan...</span>
<span className="loading loading-spinner text-primary"></span>
</div>
)}
{/* Cancel Button */}
<div className="modal-action flex justify-end pt-2">
<button type="button" className="btn" onClick={resetAndClose}>
Cancel
</button>
</div>
</div>
)
}
export default ChangeCountModal

View File

@ -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<Props> = ({ onClose }) => {
const [quantity, setQuantity] = React.useState(0)
const CountStockModal: React.FC<Props> = ({ onClose, selectedBatch }) => {
const [quantity, setQuantity] = React.useState("")
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
const [selectedEntry, setSelectedEntry] = React.useState<StockEntry | null>(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 servers 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 (
<form onSubmit={handleSubmit}>
<h3 className="font-bold text-lg">Set Stock</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<h3 className="font-bold text-lg">Count Stock</h3>
{/* Product Dropdown */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Product</span>
</label>
<div className="dropdown w-full">
<button
type="button"
className="btn w-full justify-between"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{selectedEntry ? (
<div className="flex items-center space-x-2">
<img
src={selectedEntry.physical_item.image_url}
alt={selectedEntry.physical_item.name}
className="w-6 h-6 rounded-full"
/>
<span>
{selectedEntry.physical_item.name} ({selectedEntry.count})
</span>
</div>
) : (
"Select product ..."
)}
<svg
className="fill-current w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M5.516 7.548a.75.75 0 0 1 1.06 0L10 10.972l3.424-3.424a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 0 1 0-1.06z" />
</svg>
</button>
<ul
tabIndex={0}
className={`dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full mt-1 ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{selectedBatch.stock_entries.map((entry) => (
<li key={entry.id}>
<button
type="button"
className="flex items-center px-2 py-1 hover:bg-gray-100 rounded"
onClick={() => handleSelect(entry)}
>
<img
src={entry.physical_item.image_url}
alt={entry.physical_item.name}
className="w-6 h-6 rounded-full mr-2"
/>
<span>
{entry.physical_item.name} ({entry.count})
</span>
</button>
</li>
))}
{selectedBatch.stock_entries.length === 0 && (
<li>
<span className="px-2 py-1 text-gray-500">No items available</span>
</li>
)}
</ul>
</div>
</div>
{/* Quantity Input */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Quantity</span>
</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(+e.target.value)}
onChange={(e) => setQuantity(+e.target.value)}
className="input input-bordered w-full"
required
/>
<div className="modal-action">
</div>
{/* Actions */}
<div className="modal-action flex justify-end space-x-2">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
<button
type="submit"
className="btn btn-primary"
disabled={!selectedEntry || quantity < 0}
>
Submit
</button>
</div>

View File

@ -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<StockRoom>(room);
const [activePosition, setActivePosition] = useState<() => any | null>(null);
useEffect(() => {
setData(room);
}, [room]);
const reload = async () => {
try {
const resp = await axios.get<StockRoom>(`/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 (
<div className="modal-box max-w-6xl p-6 relative">
<h3 className="text-xl font-bold mb-4 flex justify-between items-center">
<span>Edit Room: {data.room_name}</span>
<button className="btn btn-sm btn-outline" onClick={addLine}>+ Add Line</button>
</h3>
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{data.lines.map(line => (
<details key={line.line_id} className="border rounded p-2">
<summary className="flex justify-between items-center font-semibold">
<span>{line.line_name}</span>
<button className="btn btn-xs btn-outline" onClick={() => addRack(line.line_id, line.line_symbol, line.line_name)}>
+ Add Rack
</button>
</summary>
{line.racks.map(rack => (
<details key={rack.rack_id} className="ml-4 border rounded p-2">
<summary className="flex justify-between items-center">
<span>{rack.rack_name}</span>
<button className="btn btn-xxs btn-outline" onClick={() => addShelf(rack.rack_id, rack.rack_symbol, rack.rack_name)}>
+ Add Shelf
</button>
</summary>
{rack.shelves.map(shelf => (
<details key={shelf.shelf_id} className="ml-4 border rounded p-2">
<summary className="flex justify-between items-center">
<span>{shelf.shelf_name}</span>
<button className="btn btn-xxs btn-outline" onClick={() => addPosition(shelf.shelf_id, shelf.shelf_symbol, shelf.shelf_name)}>
+ Add Position
</button>
</summary>
<ul className="ml-4">
{shelf.positions.map(pos => (
<li key={pos.position_id} className="flex justify-between items-center py-1">
<span>{pos.position_name}</span>
<button
className="btn btn-xs btn-outline"
onClick={() => setActivePosition(() => () => pos)}
>
Edit Sections
</button>
</li>
))}
</ul>
</details>
))}
</details>
))}
</details>
))}
</div>
<div className="flex justify-end space-x-2 mt-4">
<button onClick={onClose} className="btn btn-outline">Close</button>
</div>
{activePosition && (
<EditStockSections
selectedPosition={activePosition}
onClose={() => {
setActivePosition(null);
reload();
}}
/>
)}
</div>
);
}

View File

@ -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<Props> = ({ onClose, selectedPosition }) => {
// Local state for position capacity and editable sections
const [positionCapacity, setPositionCapacity] = React.useState<number>(selectedPosition.capacity)
const [sections, setSections] = React.useState<EditableSection[]>([])
const [loading, setLoading] = React.useState<boolean>(false)
const [validationError, setValidationError] = React.useState<string>("")
// 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<HTMLInputElement>) => {
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<EditableSection, "section_id" | "markedForDeletion">,
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 (
<div className="space-y-4">
<h3 className="font-bold text-lg"> Edit<span className="italic opacity-75 border-l-2 pl-2 ml-2">Position {selectedPosition.storage_address}</span> </h3>
{/* Position Capacity Input */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Position Capacity</span>
</label>
<input
type="number"
value={positionCapacity || ""}
onChange={handlePositionCapacityChange}
className="input input-bordered w-full"
min="0"
required
/>
</div>
{/* Sections List */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<h4 className="font-semibold">Sections</h4>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={handleAddSection}
>
+ Add Section
</button>
</div>
{sections.map((sec, idx) => (
<div
key={sec.section_id ?? `new-${idx}`}
className={`border rounded p-3 ${
sec.markedForDeletion ? "opacity-50 italic" : ""
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium">
{sec.section_id
? `Section ID: ${sec.section_id}`
: `New Section`}
</span>
<button
type="button"
className="btn btn-error btn-xs"
onClick={() => handleRemoveSection(idx)}
>
{sec.section_id ? "Delete" : "Remove"}
</button>
</div>
{!sec.markedForDeletion && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Section Name */}
<div className="form-control">
<label className="label">
<span className="label-text">Name</span>
</label>
<input
type="text"
value={sec.section_name}
onChange={(e) =>
handleSectionChange(idx, "section_name", e.target.value)
}
className="input input-bordered w-full"
required
/>
</div>
{/* Section Capacity */}
<div className="form-control">
<label className="label">
<span className="label-text">Capacity</span>
</label>
<input
type="number"
value={sec.capacity}
onChange={(e) =>
handleSectionChange(idx, "capacity", e.target.value)
}
className="input input-bordered w-full"
min="0"
required
/>
</div>
{/* Retrievable Checkbox */}
<div className="form-control flex items-center pt-6">
<label className="label cursor-pointer">
<input
type="checkbox"
checked={sec.retrievable}
onChange={(e) =>
handleSectionChange(idx, "retrievable", e.target.checked)
}
className="checkbox checkbox-primary mr-2"
/>
<span className="label-text">Retrievable</span>
</label>
</div>
</div>
)}
{sec.markedForDeletion && (
<div className="text-sm text-red-600">
This section will be deleted when you save.
</div>
)}
</div>
))}
{sections.length === 0 && (
<div className="text-gray-500 italic">
No sections defined for this position.
</div>
)}
</div>
{/* Validation Error */}
{validationError && (
<div className="text-red-600 font-medium">{validationError}</div>
)}
<div className="text-sm text-gray-600">
Total of section capacities: <strong>{totalSectionsCapacity}</strong>
</div>
{/* Action Buttons */}
<div className="flex space-x-2 pt-4">
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={!!validationError || loading}
>
{loading ? "Saving…" : "Save Changes"}
</button>
<button
type="button"
className="btn btn-outline"
onClick={onClose}
disabled={loading}
>
Cancel
</button>
</div>
</div>
)
}
export default EditStockSections

View File

@ -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<Props> = ({
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 (
<div className="space-y-4">
<h3 className="font-bold text-lg">Move Stock Item</h3>
{!fromSection && (
<div className="alert alert-info">
Please scan the <strong>TO</strong> section
</div>
)}
{fromSection && !toSection && (
<div className="alert alert-info">
Scanned <strong>FROM:</strong> {fromSection.section_id}. Now scan the <strong>TO</strong> section
</div>
)}
{fromSection && toSection && (
<div className="alert alert-success">
Ready to move from {fromSection.section_id} {toSection.section_id}
</div>
)}
<div className="flex space-x-2">
<button
type="button"
className="btn btn-primary"
onClick={handleMove}
disabled={!fromSection || !toSection || loading}
>
{loading ? 'Moving…' : 'Confirm Move'}
</button>
<button
type="button"
className="btn"
onClick={onClose}
disabled={loading}
>
Cancel
</button>
</div>
</div>
)
}
export default MoveSectionModal

View File

@ -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<HTMLInputElement>) =>
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 (
<div className="modal-box max-w-6xl flex flex-col space-x-6 p-6 relative">
<h3 className="text-xl font-bold">New Room</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{['room_symbol','room_name'].map((field) => (
<div key={field} className="form-control">
<label className="label"><span className="label-text">{field.replace('_',' ')}</span></label>
<input
name={field}
type="text"
value={(form as any)[field]}
onChange={handleChange}
className="input input-bordered"
/>
</div>
))}
{['lines','racks_per_line','shelves_per_rack','positions_per_shelf'].map((field) => (
<div key={field} className="form-control">
<label className="label"><span className="label-text">{field.replace(/_/g,' ')}</span></label>
<input
name={field}
type="number"
min={1}
value={(form as any)[field]}
onChange={handleChange}
className="input input-bordered"
/>
</div>
))}
</div>
<div className="flex justify-end space-x-2">
<button onClick={onClose} className="btn btn-outline" disabled={loading}>Cancel</button>
<button onClick={save} className="btn btn-primary" disabled={loading}>
{loading ? 'Saving…' : 'Create'}
</button>
</div>
</div>
);
}

View File

@ -1,48 +1,391 @@
// components/modals/SetStockModal.tsx
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
selectedBatch: StockBatch
selectedSection: StockSection | null
selectedPosition: StockPosition | null
}
const SetStockModal: React.FC<Props> = ({ onClose }) => {
const [quantity, setQuantity] = React.useState(0)
const SetStockModal: React.FC<Props> = ({onClose, selectedBatch, selectedSection, selectedPosition}) => {
const [quantity, setQuantity] = React.useState<string>('')
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
const [selectedEntry, setSelectedEntry] = React.useState<StockEntry | null>(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!')
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()
} catch {
toast.error('Failed to set stock')
}
}
return (
<form onSubmit={handleSubmit}>
<h3 className="font-bold text-lg">Set Stock</h3>
<div className="space-y-4">
<h3 className="font-bold text-lg">Store Stock</h3>
<div className="tabs tabs-box">
<button
className={`tab ${activeTab === 'batch' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('batch')}
>
Entire batch
</button>
<button
className={`tab ${activeTab === 'item' ? 'tab-active' : ''}`}
onClick={() => setActiveTab('item')}
>
Individual items
</button>
</div>
{activeTab === 'batch' && (
<div className="bg-base-100 border-base-300 p-6">
{(selectedSection || selectedPosition) && (
<div className="mb-2 flex items-center text-primary">
Selected position: {selectedPosition ? selectedPosition.storage_address : selectedSection ? selectedSection.position.storage_address : ""}
</div>
)}
<div className="flex items-center space-x-2 mt-2">
<span>{loading ? 'Storing…' : (selectedSection || selectedPosition) ? 'Waiting for confirm' : 'Waiting for section scan...'}</span>
<span className="loading loading-spinner text-primary"></span>
</div>
<div className="flex space-x-2">
<button type="button" className="btn" onClick={() => setIsSuggestionsOpen(true)}>
Suggested Sections
</button>
<button
type="button"
className={`btn btn-primary ${loading ? 'loading' : ''}`}
onClick={handleScan}
disabled={!selectedSection && !selectedPosition}
>
{loading ? 'Storing…' : selectedSection || selectedPosition ? 'Confirm' : 'Waiting for section scan...'}
</button>
</div>
</div>
)}
{activeTab === 'item' && (
<div className="bg-base-100 border-base-300 p-6">
{/* Product Dropdown */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Quantity</span>
<span className="label-text">Product</span>
</label>
<div className="dropdown w-full">
<button
type="button"
className="btn w-full justify-between"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{selectedEntry ? (
<div className="flex items-center space-x-2">
<img
src={selectedEntry.physical_item.image_url}
alt={selectedEntry.physical_item.name}
className="w-6 h-6 rounded-full"
/>
<span>
{selectedEntry.physical_item.name} ({selectedEntry.count_stocked} uskladneno | {selectedEntry.count-selectedEntry.count_stocked} zbyva) {selectedEntry.sections.length > 0 ? `[${selectedEntry.sections[0].storage_address}]` : ''}
</span>
</div>
) : (
'Select product ...'
)}
<svg
className="fill-current w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M5.516 7.548a.75.75 0 0 1 1.06 0L10 10.972l3.424-3.424a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 0 1 0-1.06z" />
</svg>
</button>
<ul
tabIndex={0}
className={`dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full mt-1 ${
isDropdownOpen ? 'block' : 'hidden'
}`}
>
{selectedBatch.stock_entries.map((entry) => {
const stocked = entry.count_stocked
const total = entry.count
const remaining = total - stocked
const selectable = remaining > 0
return (
<li
key={entry.id}
className={`${!selectable ? 'opacity-50 filter' : ''}`}
>
<button
type="button"
disabled={!selectable}
onClick={selectable ? () => handleSelect(entry) : undefined}
className={
`
flex items-center px-2 py-1 rounded
hover:border-gray-100
${!selectable ? 'cursor-not-allowed' : 'hover:border-gray-100'}
`
}
>
<img
src={entry.physical_item.image_url}
alt={entry.physical_item.name}
className="w-6 h-6 rounded-full mr-2"
/>
<span>
{entry.physical_item.name} ({entry.count_stocked} uskladneno | {remaining} zbyva) {entry.sections.length > 0 ? `[${entry.sections[0].storage_address}]` : ''}
</span>
</button>
</li>
)
})}
{selectedBatch.stock_entries.length === 0 && (
<li>
<span className="px-2 py-1 text-gray-500">No items available</span>
</li>
)}
</ul>
</div>
</div>
{/* Quantity Input */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Quantity to store</span>
</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(+e.target.value)}
className="input input-bordered w-full"
onChange={(e) => setQuantity(e.target.value)}
className={`input input-bordered w-full ${hasError ? 'border-red-500' : ''}`}
min="1"
max={selectedEntry ? maxItem : undefined}
required
/>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Cancel
{selectedEntry && (
<span className="text-sm text-gray-500 mt-1">
Max items: {maxItem} | Capacity: {capacity}
</span>
)}
{/* Validation Messages */}
{(exceedsItem || exceedsCapacity) && (
<div className="mt-2 space-y-1">
{exceedsItem && (
<div className="flex items-center text-red-600">
<FontAwesomeIcon icon={faTimesCircle} className="mr-2" />
<span>Item quantity exceeded</span>
</div>
)}
{exceedsCapacity && (
<div className="flex items-center text-red-600">
<FontAwesomeIcon icon={faTimesCircle} className="mr-2" />
<span>Maximum capacity exceeded</span>
</div>
)}
</div>
)}
{/* Success indicator if valid */}
{readyToScan && (
<div className="mt-2 flex items-center text-green-600">
<FontAwesomeIcon icon={faCheckCircle} className="mr-2" />
<span>Ready to store</span>
</div>
)}
</div>
{selectedSection && (
<div className="mt-2 mb-2 flex items-center text-primary">
Selected section: {selectedSection.storage_address}
</div>
)}
{/* Buttons: Suggested Sections & Simulate Scan */}
<div className="flex space-x-2">
<button type="button" className="btn" onClick={() => setIsSuggestionsOpen(true)}>
Suggested Sections
</button>
<button type="submit" className="btn btn-primary">
Submit
<button
type="button"
className={`btn btn-primary ${loading ? 'loading' : ''}`}
onClick={handleScan}
disabled={!readyToScan || loading}
>
{loading ? 'Storing…' : selectedSection ? 'Confirm' : 'Waiting for section scan...'}
</button>
</div>
</form>
{/* Waiting for scan indicator */}
{readyToScan && !loading && (
<div className="flex items-center space-x-2 mt-2">
<span>{loading ? 'Storing…' : selectedSection ? 'Waiting for confirm' : 'Waiting for section scan...'}</span>
<span className="loading loading-spinner text-primary"></span>
</div>
)}
{/* Cancel Button */}
<div className="modal-action flex justify-end pt-2">
<button type="button" className="btn" onClick={resetAndClose}>
Cancel
</button>
</div>
{/* Suggested Sections Modal */}
{isSuggestionsOpen && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Suggested Sections</h3>
<ul className="mt-4 space-y-2">
<li>Section A (ID: 1)</li>
<li>Section B (ID: 2)</li>
<li>Section C (ID: 3)</li>
<li>Section D (ID: 4)</li>
</ul>
<div className="modal-action">
<button className="btn" onClick={() => setIsSuggestionsOpen(false)}>
Close
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -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<Props> = ({onClose, selectedSection, selectedPosition}) => {
const [quantity, setQuantity] = React.useState<string>('')
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false)
const [selectedEntry, setSelectedEntry] = React.useState<StockEntry | null>(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 (
<div className="space-y-4">
<h3 className="font-bold text-lg">Store Stock</h3>
<div className="bg-base-100 border-base-300 p-6">
{/* Product Dropdown */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Product</span>
</label>
<div className="dropdown w-full">
<button
type="button"
className="btn w-full justify-between"
onClick={() => setIsDropdownOpen((prev) => !prev)}
>
{selectedEntry ? (
<div className="flex items-center space-x-2">
<img
src={selectedEntry.physical_item.image_url}
alt={selectedEntry.physical_item.name}
className="w-6 h-6 rounded-full"
/>
<span>
{selectedEntry.physical_item.name} ({selectedEntry.count_stocked} uskladneno | {selectedEntry.count - selectedEntry.count_stocked} zbyva) [{selectedEntry.sections[0].storage_address}]
</span>
</div>
) : (
'Select product ...'
)}
<svg
className="fill-current w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path
d="M5.516 7.548a.75.75 0 0 1 1.06 0L10 10.972l3.424-3.424a.75.75 0 1 1 1.06 1.06l-4 4a.75.75 0 0 1-1.06 0l-4-4a.75.75 0 0 1 0-1.06z"/>
</svg>
</button>
<ul
tabIndex={0}
className={`dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full mt-1 ${
isDropdownOpen ? 'block' : 'hidden'
}`}
>
{sectionEntries.map((entry) => {
const stocked = entry.count_stocked
const total = entry.count
const remaining = total - stocked
return (
<li
key={entry.id}
>
<button
type="button"
onClick={() => handleSelect(entry)}
className="flex items-center px-2 py-1 rounded hover:border-gray-100"
>
<img
src={entry.physical_item.image_url}
alt={entry.physical_item.name}
className="w-6 h-6 rounded-full mr-2"
/>
<span>
{entry.physical_item.name} ({entry.count_stocked} uskladneno | {remaining} zbyva) [{entry.sections[0].storage_address}]
</span>
</button>
</li>
)
})}
{sectionEntries.length === 0 && (
<li>
<span className="px-2 py-1 text-gray-500">No items available</span>
</li>
)}
</ul>
</div>
</div>
{/* Quantity Input */}
<div className="form-control w-full">
<label className="label">
<span className="label-text">Quantity to store</span>
</label>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className={`input input-bordered w-full ${hasError ? 'border-red-500' : ''}`}
min="1"
max={selectedEntry?.count}
required
/>
{selectedEntry && (
<span className="text-sm text-gray-500 mt-1">
Max items: {selectedEntry?.count} | Capacity: {capacity}
</span>
)}
{/* Validation Messages */}
{(exceedsItem || exceedsCapacity) && (
<div className="mt-2 space-y-1">
{exceedsItem && (
<div className="flex items-center text-red-600">
<FontAwesomeIcon icon={faTimesCircle} className="mr-2"/>
<span>Item quantity exceeded</span>
</div>
)}
{exceedsCapacity && (
<div className="flex items-center text-red-600">
<FontAwesomeIcon icon={faTimesCircle} className="mr-2"/>
<span>Maximum capacity exceeded</span>
</div>
)}
</div>
)}
{/* Success indicator if valid */}
{readyToScan && (
<div className="mt-2 flex items-center text-green-600">
<FontAwesomeIcon icon={faCheckCircle} className="mr-2"/>
<span>Ready to store</span>
</div>
)}
</div>
{selectedSection && (
<div className="mt-2 mb-2 flex items-center text-primary">
Selected section: {selectedSection.storage_address}
</div>
)}
{/* Buttons: Suggested Sections & Simulate Scan */}
<div className="flex space-x-2">
<button type="button" className="btn" onClick={() => setIsSuggestionsOpen(true)}>
Suggested Sections
</button>
<button
type="button"
className={`btn btn-primary ${loading ? 'loading' : ''}`}
onClick={handleScan}
disabled={!readyToScan || loading}
>
{loading ? 'Storing…' : selectedSection ? 'Confirm' : 'Waiting for section scan...'}
</button>
</div>
{/* Waiting for scan indicator */}
{readyToScan && !loading && (
<div className="flex items-center space-x-2 mt-2">
<span>{loading ? 'Storing…' : selectedSection ? 'Waiting for confirm' : 'Waiting for section scan...'}</span>
<span className="loading loading-spinner text-primary"></span>
</div>
)}
{/* Cancel Button */}
<div className="modal-action flex justify-end pt-2">
<button type="button" className="btn" onClick={resetAndClose}>
Cancel
</button>
</div>
{/* Suggested Sections Modal */}
{isSuggestionsOpen && (
<div className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg">Suggested Sections</h3>
<ul className="mt-4 space-y-2">
<li>Section A (ID: 1)</li>
<li>Section B (ID: 2)</li>
<li>Section C (ID: 3)</li>
<li>Section D (ID: 4)</li>
</ul>
<div className="modal-action">
<button className="btn" onClick={() => setIsSuggestionsOpen(false)}>
Close
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default SetStockModal

View File

@ -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<RowHandle, ParcelRowProps>(
({ 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 (
<div onClick={handleRowClick} className={containerClasses}>
<div>
<p className="font-bold">{shipment.shipment_reference}</p>
<div className="mt-2 space-y-2">
{shipment.items.map((item) => (
<div key={item.id} className="border-t-2 border-warning pt-2 space-y-1">
<p className="text-base-content">
{item.quantity}× {item.name}
</p>
<div className="flex justify-between text-sm text-base-content">
<p>{item.model_number}</p>
<p>
{(item.price / item.quantity).toFixed(2)}{" "}
{shipment.currency}
</p>
</div>
<div
className={`mt-2 flex flex-wrap gap-2 p-2 border rounded-lg transition-colors duration-300 ${
animationPhase === "bounce"
? "bg-primary text-primary-content"
: "bg-base-200 text-base-content"
} border-base-300`}
>
{Object.entries(item.stockData).map(
([stockName, stockArray]) =>
stockArray.map((stock, idx) => (
<div key={`${stockName}-${idx}`} className="text-sm">
<p className="font-semibold">{stockName}</p>
<p>Sklad: {stock.location}</p>
<p>{stock.count} ks</p>
</div>
))
)}
</div>
</div>
))}
</div>
</div>
{animationPhase === "bounce" ? (
<FontAwesomeIcon
icon={faSquareCheck}
className="w-8 h-8 text-primary animate-bounce"
/>
) : (
<button
onClick={handleProcess}
className="btn btn-primary btn-sm self-end"
>
Process
</button>
)}
</div>
);
}
);
// ------------------- ProcessedRow (for marking as unprocessed) -------------------
interface ProcessedRowProps {
shipment: ShipmentRequest;
onUnprocess: (shipment: ShipmentRequest) => void;
onOpenDialog: (shipment: ShipmentRequest, type: "processed") => void;
}
const ProcessedRow = forwardRef<RowHandle, ProcessedRowProps>(
({ 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 (
<div onClick={handleRowClick} className={containerClasses}>
<div>
<p className="font-bold">{shipment.shipment_reference}</p>
<div className="mt-2 space-y-2">
{shipment.items.map((item) => (
<div key={item.id} className="border-t-2 border-warning pt-2 space-y-1">
<p className="text-base-content">
{item.quantity}× {item.name}
</p>
<div className="flex justify-between text-sm text-base-content">
<p>{item.model_number}</p>
<p>
{(item.price / item.quantity).toFixed(2)}{" "}
{shipment.currency}
</p>
</div>
<div
className={`mt-2 flex flex-wrap gap-2 p-2 border rounded-lg transition-colors duration-300 ${
animationPhase === "bounce"
? "bg-error text-error-content"
: "bg-base-200 text-base-content"
} border-base-300`}
>
{Object.entries(item.stockData).map(
([stockName, stockArray]) =>
stockArray.map((stock, idx) => (
<div key={`${stockName}-${idx}`} className="text-sm">
<p className="font-semibold">{stockName}</p>
<p>Sklad: {stock.location}</p>
<p>{stock.count} ks</p>
</div>
))
)}
</div>
</div>
))}
</div>
</div>
{animationPhase === "bounce" ? (
<FontAwesomeIcon
icon={faXmark}
className="w-8 h-8 text-error animate-bounce"
/>
) : (
<button
onClick={handleUnprocess}
className="btn btn-error btn-sm self-end"
>
Unprocess
</button>
)}
</div>
);
}
);
type InertiaProps = {
auth: any; // adjust per your auth type
selectedBatchID: number;
};
// ------------------- Main Component -------------------
export default function WarehouseExpedicePage() {
const { selectedBatchID } = usePage<InertiaProps>().props;
// States for shipments
const [parcels, setParcels] = useState<ShipmentRequest[]>([]);
const [parcelsOther, setParcelsOther] = useState<ShipmentRequest[]>([]);
const [processed, setProcessed] = useState<ShipmentRequest[]>([]);
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<ShipmentRequest | null>(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<HTMLDialogElement>(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 <div className="p-4 text-base-content">Loading...</div>;
}
return (
<>
{/* Top bar */}
<div className="flex justify-between items-center bg-base-100 p-4 shadow">
<a className="link" href={route('dashboard')}><FontAwesomeIcon icon={faArrowLeft} /> Back</a>
<button className="btn btn-ghost">
<FontAwesomeIcon icon={faQuestionCircle} /> Help
</button>
</div>
{/* Open the modal using document.getElementById('ID').showModal() method */}
<button className="btn" onClick={()=>openPdaModal()}>open modal</button>
{/* PDA VIEW DIALOG */}
<dialog id="pdaViewModal" className="modal" ref={pdaDialogRef}>
<div className="modal-box max-w-4xl max-h-[90vh] overflow-hidden p-0">
<PdaView closeParent={closePdaModal} />
</div>
<form method="dialog" className="modal-backdrop">
<button className="btn">Close</button>
</form>
</dialog>
{/* Tabs */}
<div className="tabs tabs-box w-full">
{/* Parcels Tab */}
<label className="tab flex-1">
<input
type="radio"
name="expedice_tabs"
className="tab"
aria-label="Parcels"
defaultChecked
/>
<div className="flex items-center justify-center gap-2 w-full">
<FontAwesomeIcon icon={faTruck} className="w-5 h-5" />
<span className="text-base-content">Parcels</span>
</div>
</label>
<div className="tab-content bg-base-100 border-base-300 p-6">
<div className="space-y-3">
{parcels.length === 0 ? (
<p className="text-base-content">No parcels available.</p>
) : (
parcels.map((shipment) => (
<ParcelRow
ref={(el) => {
parcelRowRefs.current[shipment.id] = el;
}}
key={shipment.id}
shipment={shipment}
onProcess={markAsProcessed}
onOpenDialog={openDialog}
/>
))
)}
</div>
</div>
{/* Nahravacky Tab */}
<label className="tab flex-1">
<input
type="radio"
name="expedice_tabs"
className="tab"
aria-label="Nahravacky"
/>
<div className="flex items-center justify-center gap-2 w-full">
<FontAwesomeIcon icon={faBarcode} className="w-5 h-5" />
<span className="text-base-content">Nahravacky</span>
</div>
</label>
<div className="tab-content bg-base-100 border-base-300 p-6">
<div className="space-y-3">
{parcelsOther.length === 0 ? (
<p className="text-base-content">No parcels available.</p>
) : (
parcelsOther.map((shipment) => (
<ParcelRow
ref={(el) => {
parcelRowRefs.current[shipment.id] = el;
}}
key={shipment.id}
shipment={shipment}
onProcess={markAsProcessed}
onOpenDialog={openDialog}
/>
))
)}
</div>
</div>
{/* Processed Tab (15% width) */}
<label className="tab w-[15%] flex-none">
<input
type="radio"
name="expedice_tabs"
className="tab"
aria-label="Processed"
/>
<div className="flex items-center justify-center w-full">
<FontAwesomeIcon icon={faSquareCheck} className="w-5 h-5" />
</div>
</label>
<div className="tab-content bg-base-100 border-base-300 p-6">
<div className="space-y-3">
{processed.length === 0 ? (
<p className="text-base-content">No processed shipments.</p>
) : (
processed.map((shipment) => (
<ProcessedRow
ref={(el) => {
processedRowRefs.current[shipment.id] = el;
}}
key={shipment.id}
shipment={shipment}
onUnprocess={markAsUnprocessed}
onOpenDialog={openDialog}
/>
))
)}
</div>
</div>
</div>
{/* Shipment Details Dialog */}
{selectedShipment && (
<WarehouseExpediceDialog
selectedShipment={selectedShipment}
isDialogOpen={isDialogOpen}
closeDialog={closeDialog}
handleDialogProcess={handleDialogProcess}
selectedType={selectedType}
buttonStates={buttonStates}
setButtonStates={setButtonStates}
/>
)}
</>
);
}

View File

@ -1,4 +1,4 @@
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'
@ -8,7 +8,7 @@ import {
faBoxOpen,
faClipboardList,
faCubes,
faPlus,
faPlus, faBarcode,
} from '@fortawesome/free-solid-svg-icons'
import {toast, Toaster} from 'react-hot-toast'
@ -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,6 +48,22 @@ type TileConfig = {
onClick?: () => void
}
type ModalKey =
'setStock'
| 'setStockFromTemp'
| 'otherReplacement'
| 'countStock'
| 'batchInfo'
| 'editStock'
| 'moveStock'
| 'changeCount'
| null
type PdaViewProps = { closeParent: () => void };
export default function PdaView({closeParent}: PdaViewProps) {
const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
Expedice: {
stockSectionScanned: [
@ -58,10 +84,22 @@ const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
},
{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: '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') },
{
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')},
@ -69,19 +107,35 @@ const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
},
Skladnik: {
batchScan: [
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{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: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{ title: 'Tisk QR kod na krabice', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
{
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: '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') },
{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')},
@ -89,9 +143,6 @@ const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
},
}
type ModalKey = 'setStock' | 'otherReplacement' | 'countStock' | null
export default function PdaView() {
const {
auth: {user},
} = usePage<{ auth: { user: { role: string } } }>().props
@ -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<StockSection | null>(null)
const selectedPositionRef = React.useRef<StockPosition | null>(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<StockBatch>();
const [selectedPosition, setSelectedPosition] = React.useState<StockPosition>();
const [selectedSection, setSelectedSection] = React.useState<StockSection>();
const [prevPosition, setPrevPosition] = useState<StockPosition | null>(null)
const [prevSection, setPrevSection] = useState<StockSection | null>(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 (
<>
<Head title="PDA View"/>
<audio id="audio_error" src={error_scanner_sound}>
</audio>
{/* Top bar */}
<div className="flex justify-between items-center bg-base-100 p-4 shadow">
<a className="link" href={route('dashboard')}><FontAwesomeIcon icon={faArrowLeft}/> Back</a>
{!selectedSerial && (
<button className="btn btn-ghost" onClick={handlePortRequest}>
<FontAwesomeIcon icon={faBarcode}/>
</button>
)}
<button className="btn btn-ghost">
<FontAwesomeIcon icon={faQuestionCircle}/> Help
</button>
</div>
{/* Admin tabs */}
@ -158,8 +450,17 @@ export default function PdaView() {
{/* Tiles */}
<TileLayout>
{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 isnt 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 }) => (
<Tile
key={title}
title={title}
@ -169,14 +470,32 @@ export default function PdaView() {
else if (onClick) onClick()
}}
/>
))}
))
})()}
</TileLayout>
{/* Single Modal */}
<ModalPDA isOpen={activeModal !== null} onClose={closeModal}>
{activeModal === 'setStock' && <SetStockModal onClose={closeModal} />}
{activeModal === 'batchInfo' && <BatchInfoModal onClose={closeModal} selectedBatch={selectedBatch}/>}
{activeModal === 'setStock' &&
<SetStockModal onClose={closeModal} selectedBatch={selectedBatch} selectedSection={selectedSection}
selectedPosition={selectedPosition}/>}
{activeModal === 'setStockFromTemp' &&
<SetStockModalFromTemp onClose={closeModal} selectedSection={selectedSection}
selectedPosition={selectedPosition}/>}
{activeModal === 'otherReplacement' && <OtherReplacementModal onClose={closeModal}/>}
{activeModal === 'countStock' && <CountStockModal onClose={closeModal} />}
{activeModal === 'countStock' && <CountStockModal onClose={closeModal} selectedBatch={selectedBatch}/>}
{activeModal === 'editStock' &&
<EditStockSections onClose={closeModal} selectedPosition={selectedPosition}/>}
{activeModal === 'moveStock' &&
<MoveSectionModal onClose={closeModal}
fromPosition={prevPosition}
fromSection={prevSection}
toPosition={selectedPosition}
toSection={selectedSection}/>
}
{activeModal === 'changeCount' &&
<ChangeCountModal onClose={closeModal} selectedPosition={selectedPosition}/>}
</ModalPDA>
<Toaster position="top-right"/>

View File

@ -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 (
<AppLayout
title="Expedice batches"
renderHeader={() => (
<h2 className="font-semibold text-xl">Expedice batches</h2>
)}
>
<Head title="Expedice batches"/>
<Toaster position="top-center"/>
<div className="p-4">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
{loading ? (
<div className="p-4">Loading...</div>
) : (
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Carrier</th>
<th>Results</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{batches.filter(b => b.item_count > 0).map((batch) => (
<tr key={batch.id} onClick={() => window.location.href = `/expedice/${batch.id}`}>
<td>{batch.id}</td>
<td>
<div className="flex gap-3 items-center">
<div className="avatar">
<div className="w-8 rounded-2">
<img
src={`https://www.dalkove-ovladace.cz/includes/templates/vat_responsive/images/shipping/small_icons/${batch.carrier_master.img}`}/>
</div>
</div>
<div>
{batch.carrier_master.shortname}
</div>
</div>
</td>
<td>
<div className="flex gap-4 flex-col">
<div className="flex gap-3 items-center text-green-500">
<FontAwesomeIcon icon={faSquareCheck} />
{batch.item_count}
</div>
<div className="flex gap-3 items-center text-red-400">
<FontAwesomeIcon icon={faCircleXmark} />
{batch.error_count}
</div>
</div>
</td>
<td>{batch.created_at}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</AppLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,80 @@
import React, {useEffect, useRef, useState} from 'react';
import {Inertia} from '@inertiajs/inertia';
import {Head, router, usePage} from '@inertiajs/react';
import NewRoomModal from '@/Components/modals/NewRoomModal';
import EditRoomModal from '@/Components/modals/EditRoomModal';
import axios from "axios";
import {Toaster} from "react-hot-toast";
import AppLayout from "@/Layouts/AppLayout";
import BatchInfoWindow from "@/Components/BatchInfoWindow";
export default function StorageSetup() {
const {rooms} = usePage<{ rooms: any[] }>().props;
const [showNew, setShowNew] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null);
// Modal ref
const dialogRef = useRef<HTMLDialogElement>(null);
const openModal = () => dialogRef.current?.showModal();
const closeModal = () => dialogRef.current?.close();
return (
<AppLayout title="Storage setup" renderHeader={() => <h2 className="font-semibold text-xl">Storage setup</h2>}>
<Head title="Storage setup"/>
<Toaster position="top-center"/>
<div className="py-12">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">Rooms</h1>
<button onClick={() => {setShowNew(true); openModal();}} className="btn btn-primary">
+ New Room
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{rooms.map(room => (
<div
key={room.room_id}
className="card p-4 cursor-pointer hover:shadow"
onClick={() => {setEditingRoom(room); openModal();}}
>
<h2 className="text-lg font-medium">{room.room_name}</h2>
<p className="text-sm text-gray-500">{room.room_symbol}</p>
</div>
))}
{rooms.length === 0 && (
<p className="italic text-gray-500">No rooms defined yet.</p>
)}
</div>
</div>
<dialog ref={dialogRef} className="modal">
{showNew && (
<NewRoomModal
onClose={() => {setShowNew(false); closeModal();}}
onSaved={() => {
Inertia.reload();
setShowNew(false);
}}
/>
)}
{editingRoom && (
<EditRoomModal
room={editingRoom}
onClose={() => {setEditingRoom(null); closeModal();}}
onUpdated={() => {
Inertia.reload();
setEditingRoom(null);
}}
/>
)}
</dialog>
</div>
</AppLayout>
);
}

View File

@ -0,0 +1,33 @@
import axios from "axios";
import {toast} from "react-hot-toast";
export function downloadBatchBarcode(batchId: number) {
try {
axios
.post('/api/pda/batchBarcodePrint', {id: batchId})
.then((response) => {
const dataUri = response.data.data
if (!dataUri) {
console.error('No image data found in JSON response')
return
}
// Create a temporary <a> element and trigger download
const link = document.createElement('a')
link.href = dataUri
link.download = `barcode_${batchId}.png`
// Append to DOM, click it, then remove it
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
.catch((error) => {
console.error(error)
toast.error('Failed to fetch or download barcode')
})
}
catch (e) {
console.error(e)
toast.error('Failed to fetch or download barcode')
}
}

View File

@ -0,0 +1,313 @@
import {colors} from "@material-tailwind/react/types/generic";
export interface ShipmentRequest {
id: number;
shipment_reference: string;
note: string;
user_id: number,
delivery_address_name: string;
delivery_address_company_name: string | null;
delivery_address_street_name: string;
delivery_address_street_number: string;
delivery_address_city: string;
delivery_address_zip: string;
delivery_address_state_iso: string | null;
delivery_address_country_iso: string;
pickup_point_code: string | null;
postnummer: string | null;
contact_email: string;
contact_telephone: string;
cod_value: string | null;
cod_variable_symbol: string | null;
shipment_value: string;
currency: string;
weight: string;
height: number;
width: number;
length: number;
carrier: Carrier;
package_type: string;
created_at: Date;
updated_at: Date;
delivery_address: DeliveryAddress;
shipment: Shipment;
current_shipment_status: ShipmentStatusHistory;
shipment_statuses: ShipmentStatusHistory[],
user: User;
batch: Batch;
items: Item[];
shipment_price: number;
}
export interface Item {
id: number;
shipment_request_id: number;
name: string;
item_note: string | null;
model_number: string;
HSCode: string;
price: string;
quantity: number;
originCountry: string;
weight: string;
item_id_internal_warehouse: number;
item_id_external: number;
created_at: string;
updated_at: string;
imageUrl: string;
}
interface Shipment {
id: number;
user_id: number;
shipment_request_id: number;
internal_shipment_id: string;
shipment_id: string;
tracking_number: string;
created_at: string;
updated_at: string;
}
export interface ShipmentStatusHistory {
id: number;
shipment_status_id: number;
shipment_id: number;
status_note: string;
shipment_status: ShipmentStatus;
}
export interface ShipmentStatus {
id: number;
name: string;
description: string;
}
export interface DeliveryAddress {
name: string;
companyName: string | null;
streetName: string;
streetNumber: string;
city: string;
zip: string;
stateISO: string | null;
countryISO: string;
}
export interface Batch {
carrier_master: CarrierMaster;
id: number;
user: User;
item_count: number;
error_count: number;
created_at: string;
updated_at: string;
processed: boolean;
printed: boolean;
shipment_request: ShipmentRequest;
latest_generated_file: File;
}
export interface Error {
id: number;
shipment_request_id: number;
fetched: boolean;
error_message: string;
created_at: string;
updated_at: string;
}
export interface ErrorObject {
error: Error;
shipment_request: ShipmentRequest;
batch_id: number;
}
export interface CarrierMaster {
id: number;
display_name: string;
shortname: string;
img: string;
api_url: string;
api_url_sandbox: string;
label_reprint: boolean;
carrier_enabled: boolean;
carriers: Carrier[]
}
export interface Carrier {
id: number;
internal: string;
ext_id: string;
carrier_name: string;
carrier_shortname: string;
pickup_points: boolean;
carrier_contract: string; // Adjust the type if necessary
customs_declarations: boolean; // Adjust the type if necessary
cod_allowed: boolean;
cod_enabled: boolean;
cod_price: string; // Adjust the type if necessary
country: string;
zone_id: number;
currency: string;
img: string;
enabled_store: boolean;
shipping_price: string; // Adjust the type if necessary
free_shipping_enabled: boolean;
delivery_time: string; // Adjust the type if necessary
display_order: number;
api_allowed: boolean;
carrier_master: CarrierMaster;
base_pricing: CarrierBasePricing;
}
export interface AlertState {
show: boolean;
message: React.ReactNode;
color: colors;
pdfUrl: string;
file_id: number;
filename: string;
}
export interface UserDetails {
user_id: number;
apiCallbackURL: string | null;
imageURL: string | null;
callbacks_enabled: boolean;
carrier_pricings?: UserCarrierPricing[]; // Adjusted to match model and JSON data
}
export interface User {
id: number;
name: string;
email: string;
details?: UserDetails;
}
export interface UserCarrierPricing {
id: number;
user_id: number;
carrier_id: number;
cod_price?: number;
carrier?: Carrier; // Relationship to Carrier
pricing_weights?: UserCarrierPricing2Weight[]; // Adjusted to match model and JSON data
extra_fees?: UserCarrierPricingExtraFees[]; // Adjusted to match model and JSON data
}
export interface UserCarrierPricing2Weight {
id: number;
user_carrier_pricing_id: number;
weight_max: number;
shipping_price: number;
}
export interface UserCarrierPricingExtraFees {
id: number;
user_carrier_pricing_id: number;
carrier_extra_fee_type_id: number;
carrier_extra_fee_value: number;
carrier_extra_fee_value_type: string; // Could be "monetary" or other types
extra_fee_type?: CarrierExtraFeeTypes; // Relationship to CarrierExtraFeeTypes
}
export interface CarrierBasePricing {
id: number;
user_id: number;
carrier_id: number;
cod_price?: number;
pricing_weights?: CarrierBasePricing2Weight[]; // Adjusted to match model and JSON data
extra_fees?: CarrierBasePricingExtraFees[]; // Adjusted to match model and JSON data
}
export interface CarrierBasePricing2Weight {
id: number;
carrier_base_pricing_id: number;
weight_max: number;
shipping_price: number;
}
export interface CarrierBasePricingExtraFees {
id: number;
carrier_base_pricing_id: number;
carrier_extra_fee_type_id: number;
carrier_extra_fee_value: number;
carrier_extra_fee_value_type: string; // Could be "monetary" or other types
extra_fee_type?: CarrierExtraFeeTypes; // Relationship to CarrierExtraFeeTypes
}
export interface CarrierExtraFeeTypes {
id: number;
name: string;
description?: string;
}
export interface PickupPoint {
id: number;
ext_id: string;
carrier_id: number;
carrier: Carrier;
location: string;
name: string;
type: string;
directions?: string;
place_name?: string;
address: string;
zip: string;
city: string;
country_iso: string;
status_id?: number;
card?: boolean;
cod?: boolean;
wheelchair?: boolean;
photo_url?: string;
thumbnail_url?: string;
enabled: boolean;
distance: string;
shipment_price: number;
updated_at: Date;
}
export interface Files {
files: File[]
}
export interface File {
id: number;
filename: string;
user: User;
// file_data: string;
lifetime: string;
associated_id: number;
type: string;
created_at: string;
updated_at: string;
}
export interface StockList {
to_be_withdrawn: number,
name: string,
model: string,
physicalItemId: string,
mapping_breakdown: MappingProduct[]
}
export interface MappingProduct {
to_be_withdrawn: number,
name: string,
model: string,
mapping_id: string,
shipment_request_item_ids: number[]
}
export interface Task {
id: number;
user: User;
task_title: string;
task_desc: string;
related_id: number;
related_id_type: string;
comment: string;
completed: boolean;
}

Binary file not shown.

View File

@ -287,10 +287,26 @@ export interface PhysicalItem {
updatedBy: number | null;
// relations
// physicalItemType?: PhysicalItemType;
type?: PhysicalItemType;
// manufacturer?: Supplier;
}
export interface PhysicalItemType {
id: number;
name: string | null;
code_suffix: string;
_name: string;
priority: number;
weight: number | null;
volume: number | null;
title_template_id: number;
description_template_id: number;
created_at: string;
created_by: number;
updated_at: string;
updated_by: number | null;
}
export interface User {
id: number;
name: string;
@ -395,6 +411,7 @@ export interface StockPosition {
shelf_id: number;
created_at: string;
updated_at: string | null;
capacity: number;
// relations
shelf?: StockShelf;
@ -432,3 +449,10 @@ export interface StockEntries2Section {
entry?: StockEntry;
}
export type PageProps<T extends Record<string, unknown> = Record<string, unknown>> = T & {
auth: {
user: User;
};
};

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dracula">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

View File

@ -1,18 +1,31 @@
<?php
use App\Http\Controllers\Api\ExpediceController;
use App\Http\Controllers\Api\StockBatchController;
use App\Http\Controllers\Api\StockSectionController;
use App\Http\Controllers\Api\ScannerController;
use App\Http\Controllers\Api\StockEntryController;
use App\Http\Controllers\Api\StockHandleExpediceController;
use App\Http\Controllers\Api\SupplierController;
use App\Http\Controllers\Api\StockEntryStatusController;
use App\Http\Controllers\Api\StockPositionController;
use App\Http\Controllers\Api\StockRoomController;
use App\Http\Controllers\Api\StockLineController;
use App\Http\Controllers\Api\StockRackController;
use App\Http\Controllers\Api\StockShelfController;
use App\Http\Controllers\StorageController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::middleware([EnsureFrontendRequestsAreStateful::class, 'auth:sanctum'])->group(function () {
// Route::group(['middleware' => ['role:admin']], function () {
// Stock Entry endpoints
Route::get('/stockData/options', [StockEntryController::class, 'getOptions']);
Route::get('/stockData/options/items', [StockEntryController::class, 'getItems']);
@ -46,7 +59,6 @@ Route::get('/stockStatusList', [StockEntryController::class, 'getStatusList']);
// barcode scanner methods
Route::post('stockActions/itemPickup', [StockEntryController::class, 'itemPickup']);
@ -71,8 +83,6 @@ Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class,
// vybrat kolik se vejde do obalky, rating / volume
// skladnik akce
// prijde zbozi, vytiskne si X stitku s QR - nalepi na kazdou krabici + pripadne jeden "batch" stitek na palete
// vytvori batch u PC + zada vsechny produkty, ceny, ocekavane pocty
@ -93,5 +103,74 @@ Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class,
Route::post('/stockActions/{stockEntry}/status', [StockEntryStatusController::class, 'store']);
Route::get('/batchListWMS', [ExpediceController::class, 'batchListWMS']);
Route::post('/expediceListWMS', [ExpediceController::class, 'expediceListWMS']);
Route::get('/expediceListWMS/getImage', [ExpediceController::class, 'getProductImage']);
Route::post('/storage/setup', [StorageController::class, 'setup']);
Route::post('/pda/barcodeScan', [ScannerController::class, 'barcodeScan']);
Route::post('/pda/batchBarcodePrint', [ScannerController::class, 'batchBarcodePrint']);
Route::post('/pdaView/countStock', [StockEntryController::class, 'countStock']);
Route::post('/pdaView/setStockSection', [StockSectionController::class, 'setSection']);
Route::post('/pdaView/storeStock', [StockSectionController::class, 'storeStock']);
Route::post('/pdaView/setBatchSection', [StockSectionController::class, 'setSectionForBatch']);
Route::get('/pdaView/getStockPosition', [StockPositionController::class, 'getPosition']);
Route::post('/pdaView/moveStockSection', [StockSectionController::class, 'movePosition']);
Route::post('/pdaView/changeCount', [StockSectionController::class, 'changeCount']);
Route::put('/stockPositions/{id}', [StockPositionController::class, 'update']);
Route::get('/stockSections/{id}', [StockSectionController::class, 'getSection']);
Route::post('/stockSections', [StockSectionController::class, 'store']);
Route::put('/stockSections/{id}', [StockSectionController::class, 'update']);
Route::delete('/stockSections/{id}', [StockSectionController::class, 'destroy']);
//stock rooms
Route::post('/stock-rooms', [StockRoomController::class, 'store']);
Route::get('/stock-rooms/{room}', [StockRoomController::class, 'show']);
Route::put('/stock-rooms/{room}', [StockRoomController::class, 'update']);
Route::delete('/stock-rooms/{room}', [StockRoomController::class, 'destroy']);
// Lines
Route::post ('/stock-lines', [StockLineController::class, 'store']);
Route::put ('/stock-lines/{line}', [StockLineController::class, 'update']);
Route::delete ('/stock-lines/{line}', [StockLineController::class, 'destroy']);
// Racks
Route::post ('/stock-racks', [StockRackController::class, 'store']);
Route::put ('/stock-racks/{rack}', [StockRackController::class, 'update']);
Route::delete ('/stock-racks/{rack}', [StockRackController::class, 'destroy']);
// Shelves
Route::post ('/stock-shelves', [StockShelfController::class, 'store']);
Route::put ('/stock-shelves/{shelf}',[StockShelfController::class, 'update']);
Route::delete ('/stock-shelves/{shelf}',[StockShelfController::class, 'destroy']);
// Positions
Route::post ('/stock-positions', [StockPositionController::class,'store']);
Route::put ('/stock-positions/{pos}',[StockPositionController::class,'updateBasic']);
Route::delete ('/stock-positions/{pos}',[StockPositionController::class,'destroy']);
});

View File

@ -1,6 +1,7 @@
<?php
use App\Models\StockBatch;
use App\Models\StockRoom;
use Illuminate\Http\Request;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
@ -51,9 +52,29 @@ Route::middleware([
})->name('batchCounting');
// Stock Entries routes
Route::get('/pdaView', function () {
return Inertia::render('PdaView');
})->name('pdaView');
Route::get('/expediceBatches', function () {
return Inertia::render('ShippingBatchList');
})->name('expediceBatches');
Route::get('/expedice/{id}', function (Request $request, $id) {
return Inertia::render('Expedice', [
'selectedBatchID' => $id
]);
})->name('expedice');
Route::get('/storageSetup', function () {
$rooms = StockRoom::with('lines.racks.shelves.positions.sections')->get();
return Inertia::render('StorageSetup',
[
'rooms' => $rooms
]
);
})->name('storageSetup');
});