wip warehouse and inc stock workflow
This commit is contained in:
parent
e637d26842
commit
6768f8d5b7
4
.env
4
.env
@ -12,11 +12,13 @@ DB_HOST=127.0.0.1
|
||||
DB_PORT=3311
|
||||
DB_DATABASE=vat_wms
|
||||
DB_DATABASE_WAREHOUSE=vat_warehouse
|
||||
DB_DATABASE_SHIPPING=shipping
|
||||
DB_USERNAME=vat_wms
|
||||
DB_PASSWORD=vat_wms
|
||||
|
||||
|
||||
APP_KEY=base64:yptRdaDOEfbK3K+eheSvnvbD7JFYK/GUedXzVM4U2qs=
|
||||
|
||||
SHIPPING_API_KEY=4|mNZYZOhSB2gcCf0G9vJr38j9im7AtbM5Xj5BdcMr04903057
|
||||
SHIPPPING_APP_URL=https://shipping.dalkove-ovladace.cz
|
||||
#OCTANE_SERVER=roadrunner
|
||||
|
||||
|
109
app/Http/Controllers/Api/ExpediceController.php
Normal file
109
app/Http/Controllers/Api/ExpediceController.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
155
app/Http/Controllers/Api/ScannerController.php
Normal file
155
app/Http/Controllers/Api/ScannerController.php
Normal 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 checked‐in.
|
||||
// Here we just look up a Carrier and flip a boolean. Adjust to your real logic.
|
||||
$carrier = Carrier::findOrFail($id);
|
||||
$carrier->last_scanned_at = now();
|
||||
$carrier->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
], 200);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
default:
|
||||
// This will never run because our validator already limits barcode_type to the four values.
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'invalid_barcode_type',
|
||||
], 400);
|
||||
}
|
||||
} catch (ModelNotFoundException $e) {
|
||||
// Any of the findOrFail(...) calls threw a ModelNotFoundException
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => "No record found for ID {$id} under type '{$type}'.",
|
||||
], 404);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log the exception so you can inspect it later
|
||||
Log::error('Error in BarcodeController@scan', [
|
||||
'barcode_type' => $type,
|
||||
'id' => $id,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'server_error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function batchBarcodePrint(Request $request) {
|
||||
|
||||
$code = Encoder::encode(json_encode(
|
||||
[
|
||||
"barcode_type" => "stock_batch",
|
||||
"payload" => [
|
||||
'id' => $request->input('id'),
|
||||
]
|
||||
]
|
||||
));
|
||||
$renderer = new PngRenderer();
|
||||
|
||||
$aztec_code = $renderer->render($code);
|
||||
|
||||
$base64_aztec_img = base64_encode($aztec_code);
|
||||
$base64_aztec_src = "data:image/png;base64,{$base64_aztec_img}";
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $base64_aztec_src,
|
||||
], 200);
|
||||
}
|
||||
|
||||
}
|
@ -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',
|
||||
]);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
42
app/Http/Controllers/Api/StockLineController.php
Normal file
42
app/Http/Controllers/Api/StockLineController.php
Normal 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);
|
||||
}
|
||||
}
|
123
app/Http/Controllers/Api/StockPositionController.php
Normal file
123
app/Http/Controllers/Api/StockPositionController.php
Normal 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) Eager‐load the real relationships
|
||||
$position = StockPosition::with([
|
||||
'sections.entries.physicalItem',
|
||||
'shelf.rack.line.room',
|
||||
])->findOrFail(2);
|
||||
|
||||
// 2) Compute the storage address string
|
||||
$position->storage_address = $position->storageAddress();
|
||||
|
||||
// 3) Return the model itself (now including a top‐level "storage_address" field)
|
||||
return response()->json($position);
|
||||
}
|
||||
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'shelf_id' => 'required|exists:stock_shelf,shelf_id',
|
||||
'position_symbol' => 'required|string|max:50',
|
||||
'position_name' => 'required|string|max:100',
|
||||
'capacity' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$pos = StockPosition::create($data);
|
||||
|
||||
return response()->json($pos, 201);
|
||||
}
|
||||
|
||||
public function updateBasic(Request $request, StockPosition $pos)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'position_symbol' => 'sometimes|required|string|max:50',
|
||||
'position_name' => 'sometimes|required|string|max:100',
|
||||
'capacity' => 'sometimes|required|integer|min:0',
|
||||
]);
|
||||
|
||||
$pos->update($data);
|
||||
|
||||
return response()->json($pos);
|
||||
}
|
||||
|
||||
public function destroy(StockPosition $pos)
|
||||
{
|
||||
$pos->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
42
app/Http/Controllers/Api/StockRackController.php
Normal file
42
app/Http/Controllers/Api/StockRackController.php
Normal 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);
|
||||
}
|
||||
}
|
80
app/Http/Controllers/Api/StockRoomController.php
Normal file
80
app/Http/Controllers/Api/StockRoomController.php
Normal 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, let’s assume front-end will call your existing modals
|
||||
// to update positions or sections via your other APIs.
|
||||
$room->update($request->only('room_symbol','room_name'));
|
||||
return response()->json(['message'=>'Room updated']);
|
||||
}
|
||||
|
||||
public function destroy(StockRoom $room)
|
||||
{
|
||||
$room->delete();
|
||||
return response()->json(['message'=>'Room deleted']);
|
||||
}
|
||||
}
|
711
app/Http/Controllers/Api/StockSectionController.php
Normal file
711
app/Http/Controllers/Api/StockSectionController.php
Normal 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 <= section’s capacity
|
||||
if ($count > $section->capacity) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'insufficient_capacity',
|
||||
'message' => 'Not enough capacity in this section.',
|
||||
'available_capacity' => $section->capacity,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// 5) Create the pivot record and mark section as occupied
|
||||
StockEntries2Section::create([
|
||||
'entry_id' => $entry->id,
|
||||
'section_id' => $section->section_id,
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Entry assigned to section successfully.',
|
||||
], 200);
|
||||
|
||||
} catch (ModelNotFoundException $e) {
|
||||
// Either the section or the entry was not found
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => 'StockSection or StockEntry not found.',
|
||||
], 404);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log unexpected exceptions
|
||||
Log::error('Error in setSection', [
|
||||
'section_id' => $sectionId,
|
||||
'entry_id' => $entryId,
|
||||
'count' => $count,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'server_error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function storeStock(Request $request)
|
||||
{
|
||||
// 1) Validate input
|
||||
try {
|
||||
$data = $request->validate([
|
||||
'section_id' => 'required|integer|exists:stock_section,section_id', // new section
|
||||
'current_section' => 'required|integer|exists:stock_section,section_id', // old section
|
||||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||||
'count_to_be_stored' => 'required|integer|min:1',
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'validation_failed',
|
||||
'message' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$newSectionId = $data['section_id'];
|
||||
$currentSectionId = $data['current_section'];
|
||||
$entryId = $data['entry_id'];
|
||||
$moveCount = $data['count_to_be_stored'];
|
||||
|
||||
try {
|
||||
DB::transaction(function () use (
|
||||
$newSectionId,
|
||||
$currentSectionId,
|
||||
$entryId,
|
||||
$moveCount
|
||||
) {
|
||||
// 2) Load sections and entry
|
||||
$currentSection = StockSection::findOrFail($currentSectionId);
|
||||
$newSection = StockSection::findOrFail($newSectionId);
|
||||
$entry = StockEntry::findOrFail($entryId);
|
||||
|
||||
// 3) Check occupation: allow if occupied only by this same entry
|
||||
$existingInNew = StockEntries2Section::where('section_id', $newSectionId)
|
||||
->where('entry_id', $entryId)
|
||||
->first();
|
||||
if ($newSection->occupied() && ! $existingInNew) {
|
||||
abort(409, json_encode([
|
||||
'success' => false,
|
||||
'error' => 'section_occupied',
|
||||
'message' => 'Target section is already occupied by another item.',
|
||||
]));
|
||||
}
|
||||
|
||||
// 4) Locate pivot in current section
|
||||
$pivot = StockEntries2Section::where('section_id', $currentSectionId)
|
||||
->where('entry_id', $entryId)
|
||||
->firstOrFail();
|
||||
|
||||
// 5) Ensure there is enough count in current section
|
||||
if ($pivot->count < $moveCount) {
|
||||
abort(409, json_encode([
|
||||
'success' => false,
|
||||
'error' => 'insufficient_count',
|
||||
'message' => 'Not enough items in the current section to move.',
|
||||
'available' => $pivot->count,
|
||||
]));
|
||||
}
|
||||
|
||||
// 6) Decrement or remove pivot in current section
|
||||
$remaining = $pivot->count - $moveCount;
|
||||
if ($remaining > 0) {
|
||||
DB::table('stock_entries2section')
|
||||
->where('entry_id', $entryId)
|
||||
->where('section_id', $currentSectionId)
|
||||
->limit(1)
|
||||
->update(['count' => $remaining]);
|
||||
} else {
|
||||
DB::table('stock_entries2section')
|
||||
->where('entry_id', $entryId)
|
||||
->where('section_id', $currentSectionId)
|
||||
->limit(1)
|
||||
->delete();
|
||||
|
||||
// if no more entries in this section, mark it unoccupied
|
||||
if (! DB::table('stock_entries2section')->where('section_id', $currentSectionId)->exists()) {
|
||||
$currentSection->update(['occupied' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Upsert into new section, merging counts if already present
|
||||
// lockForUpdate to avoid race conditions
|
||||
$existing = DB::table('stock_entries2section')
|
||||
->where('entry_id', $entryId)
|
||||
->where('section_id', $newSectionId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$currentInNew = $existing->count ?? 0;
|
||||
$totalAfter = $currentInNew + $moveCount;
|
||||
|
||||
// 8) Ensure capacity in new section
|
||||
if ($totalAfter > $newSection->capacity) {
|
||||
abort(409, json_encode([
|
||||
'success' => false,
|
||||
'error' => 'insufficient_capacity',
|
||||
'message' => 'Not enough capacity in target section.',
|
||||
'available_capacity' => $newSection->capacity - $currentInNew,
|
||||
]));
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
DB::table('stock_entries2section')
|
||||
->where('entry_id', $entryId)
|
||||
->where('section_id', $newSectionId)
|
||||
->limit(1)
|
||||
->update(['count' => $totalAfter]);
|
||||
} else {
|
||||
StockEntries2Section::create([
|
||||
'entry_id' => $entry->id,
|
||||
'section_id' => $newSectionId,
|
||||
'count' => $moveCount,
|
||||
]);
|
||||
}
|
||||
|
||||
// 9) Mark new section occupied
|
||||
$newSection->update(['occupied' => true]);
|
||||
});
|
||||
|
||||
// success response
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Moved stock successfully.',
|
||||
], 200);
|
||||
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => 'StockSection or StockEntry not found.',
|
||||
], 404);
|
||||
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
$payload = json_decode($e->getMessage(), true);
|
||||
return response()->json($payload, $e->getStatusCode());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in storeStock', [
|
||||
'current_section' => $currentSectionId,
|
||||
'new_section' => $newSectionId,
|
||||
'entry_id' => $entryId,
|
||||
'count' => $moveCount,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'server_error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function setSectionForBatch(Request $request)
|
||||
{
|
||||
// 1) Validate input
|
||||
try {
|
||||
$data = $request->validate([
|
||||
'position_id' => 'required|integer|exists:stock_position,position_id',
|
||||
'batch_id' => 'required|integer|exists:stock_batch,id',
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'validation_failed',
|
||||
'message' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$positionId = $data['position_id'];
|
||||
$batchId = $data['batch_id'];
|
||||
|
||||
try {
|
||||
// 2) Locate section and entry (404 if not found)
|
||||
$position = StockPosition::findOrFail($positionId);
|
||||
$batch = StockBatch::with(['stockEntries'])->findOrFail($batchId);
|
||||
|
||||
$anyOccupied = $position->sections->contains(function($section) {
|
||||
return $section->occupied();
|
||||
});
|
||||
// 3) If the section is already occupied → 409 Conflict
|
||||
if ($anyOccupied) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'position_occupied',
|
||||
'message' => 'That position is already occupied.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (!$position->temporary) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'section_not_tmp',
|
||||
'message' => 'That section is not suitable for entire batch temporary storage.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// Delete all existing sections for this position
|
||||
StockSection::where('position_id', $position->position_id)->delete();
|
||||
|
||||
// Create new sections for each entry
|
||||
$sectionCounter = 1;
|
||||
foreach ($batch->stockEntries as $entry) {
|
||||
// Create new section
|
||||
$section = StockSection::create([
|
||||
'position_id' => $position->position_id,
|
||||
'section_name' => (string)$sectionCounter,
|
||||
'section_symbol' => (string)$sectionCounter,
|
||||
'capacity' => $entry->count,
|
||||
'retrievable' => true
|
||||
]);
|
||||
|
||||
// Create mapping
|
||||
StockEntries2Section::create([
|
||||
'entry_id' => $entry->id,
|
||||
'section_id' => $section->section_id,
|
||||
'count' => $entry->count
|
||||
]);
|
||||
|
||||
$sectionCounter++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Batch stored to position successfully.',
|
||||
], 200);
|
||||
|
||||
} catch (ModelNotFoundException $e) {
|
||||
// Either the section or the entry was not found
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => 'StockSection or StockEntry not found.',
|
||||
], 404);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log unexpected exceptions
|
||||
Log::error('Error in setSectionForBatch', [
|
||||
'position_id' => $positionId,
|
||||
'batch_id' => $batchId,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'server_error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function movePosition(Request $request)
|
||||
{
|
||||
// 1) Validate input
|
||||
try {
|
||||
$data = $request->validate([
|
||||
'section_id' => 'required|integer|exists:stock_section,section_id',
|
||||
'new_section_id' => 'required|integer|exists:stock_section,section_id',
|
||||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||||
'count' => 'required|integer|min:1',
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'validation_failed',
|
||||
'message' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$sectionId = $data['section_id'];
|
||||
$newSectionId = $data['new_section_id'];
|
||||
$entryId = $data['entry_id'];
|
||||
$count = $data['count'];
|
||||
|
||||
try {
|
||||
// 2) Locate section and entry (404 if not found)
|
||||
$new_section = StockSection::findOrFail($newSectionId);
|
||||
$entry = StockEntry::findOrFail($entryId);
|
||||
|
||||
// 3) If the section is already occupied → 409 Conflict
|
||||
if ($new_section->occupied()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'section_occupied',
|
||||
'message' => 'That section is already occupied.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// 4) Check capacity: count_to_be_stored must be <= section’s capacity
|
||||
if ($count > $new_section->capacity) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'insufficient_capacity',
|
||||
'message' => 'Not enough capacity in this section.',
|
||||
'available_capacity' => $new_section->capacity,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// use query builder because of composite PK
|
||||
DB::table('stock_entries2section')
|
||||
->where('entry_id', $entryId)
|
||||
->where('section_id', $sectionId)
|
||||
->limit(1) // ensure only one row is touched
|
||||
->update([
|
||||
'section_id' => $new_section->section_id,
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Section changed successfully.',
|
||||
], 200);
|
||||
|
||||
} catch (ModelNotFoundException $e) {
|
||||
// Either the section or the entry was not found
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => 'StockSection or StockEntry not found.',
|
||||
], 404);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log unexpected exceptions
|
||||
Log::error('Error in setSection', [
|
||||
'section_id' => $sectionId,
|
||||
'new_section_id' => $newSectionId,
|
||||
'entry_id' => $entryId,
|
||||
'count' => $count,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'server_error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function changeCount(Request $request)
|
||||
{
|
||||
// 1) Validate input
|
||||
try {
|
||||
$data = $request->validate([
|
||||
'section_id' => 'required|integer|exists:stock_section,section_id',
|
||||
'entry_id' => 'required|integer|exists:stock_entries,id',
|
||||
'count' => 'required|integer|min:1',
|
||||
'new_count' => 'required|integer|min:1',
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'validation_failed',
|
||||
'message' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$sectionId = $data['section_id'];
|
||||
$entryId = $data['entry_id'];
|
||||
$count = $data['count'];
|
||||
$new_count = $data['new_count'];
|
||||
|
||||
try {
|
||||
// 2) Locate section and entry (404 if not found)
|
||||
$section = StockSection::findOrFail($sectionId);
|
||||
$entry = StockEntry::findOrFail($entryId);
|
||||
|
||||
|
||||
// Check capacity: count_to_be_stored must be <= section’s capacity
|
||||
if ($new_count > $section->capacity) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'insufficient_capacity',
|
||||
'message' => 'Not enough capacity in this section.',
|
||||
'available_capacity' => $section->capacity,
|
||||
], 409);
|
||||
}
|
||||
|
||||
// use query builder because of composite PK
|
||||
DB::table('stock_entries2section')
|
||||
->where('entry_id', $entryId)
|
||||
->where('section_id', $sectionId)
|
||||
->limit(1) // ensure only one row is touched
|
||||
->update([
|
||||
'count' => $new_count,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Section changed successfully.',
|
||||
], 200);
|
||||
|
||||
} catch (ModelNotFoundException $e) {
|
||||
// Either the section or the entry was not found
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => 'StockSection or StockEntry not found.',
|
||||
], 404);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log unexpected exceptions
|
||||
Log::error('Error in setSection', [
|
||||
'section_id' => $sectionId,
|
||||
'new_section_id' => $newSectionId,
|
||||
'entry_id' => $entryId,
|
||||
'count' => $count,
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'server_error',
|
||||
'message' => 'An unexpected error occurred.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// 1) Validate incoming payload
|
||||
$validator = Validator::make($request->all(), [
|
||||
'position_id' => 'required|integer|exists:stock_position,position_id',
|
||||
'section_symbol' => 'required|string|max:5',
|
||||
'section_name' => 'required|string|max:32',
|
||||
'capacity' => 'required|integer|min:0',
|
||||
'retrievable' => 'required|boolean',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()
|
||||
->json([
|
||||
'success' => false,
|
||||
'error' => 'validation_failed',
|
||||
'message' => $validator->errors()->first(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 2) Find the parent position
|
||||
$position = StockPosition::find($request->input('position_id'));
|
||||
if (!$position) {
|
||||
return response()
|
||||
->json([
|
||||
'success' => false,
|
||||
'error' => 'not_found',
|
||||
'message' => 'Position not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$newCapacity = $request->input('capacity');
|
||||
|
||||
// 3) Check that (existing sections sum) + newCapacity ≤ position.capacity
|
||||
$existingSum = $position
|
||||
->sections()
|
||||
->sum('capacity');
|
||||
|
||||
if ($existingSum + $newCapacity > $position->capacity) {
|
||||
return response()
|
||||
->json([
|
||||
'success' => false,
|
||||
'error' => 'insufficient_capacity',
|
||||
'message' => "Creating this section would push total capacity ({$existingSum} + {$newCapacity}) over position capacity ({$position->capacity}).",
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 4) Create the new section
|
||||
$section = new StockSection();
|
||||
$section->position_id = $position->position_id;
|
||||
$section->section_symbol = $request->input('section_symbol');
|
||||
$section->section_name = $request->input('section_name');
|
||||
$section->capacity = $newCapacity;
|
||||
$section->retrievable = $request->input('retrievable');
|
||||
$section->save();
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'success' => true,
|
||||
'section_id' => $section->section_id,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing StockSection.
|
||||
*
|
||||
* Endpoint: PUT /api/stockSections/{id}
|
||||
*
|
||||
* Request payload:
|
||||
* {
|
||||
* "section_name": <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);
|
||||
}
|
||||
|
||||
}
|
42
app/Http/Controllers/Api/StockShelfController.php
Normal file
42
app/Http/Controllers/Api/StockShelfController.php
Normal 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);
|
||||
}
|
||||
}
|
102
app/Http/Controllers/Api/StorageController.php
Normal file
102
app/Http/Controllers/Api/StorageController.php
Normal 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 bulk‐creation 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);
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
70
app/Models/PhysicalItemType.php
Normal file
70
app/Models/PhysicalItemType.php
Normal 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');
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
198
composer.lock
generated
@ -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": [
|
||||
|
@ -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'
|
||||
|
||||
];
|
||||
|
@ -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
263
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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';
|
||||
|
||||
|
209
resources/js/Components/BatchInfoWindow.tsx
Normal file
209
resources/js/Components/BatchInfoWindow.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
292
resources/js/Components/ShipmentItemsAccordion.tsx
Normal file
292
resources/js/Components/ShipmentItemsAccordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
265
resources/js/Components/WarehouseExpediceDialog.tsx
Normal file
265
resources/js/Components/WarehouseExpediceDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
35
resources/js/Components/modals/BatchInfoModal.tsx
Normal file
35
resources/js/Components/modals/BatchInfoModal.tsx
Normal 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
|
210
resources/js/Components/modals/ChangeCountModal.tsx
Normal file
210
resources/js/Components/modals/ChangeCountModal.tsx
Normal 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
|
@ -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 server’s JSON error payload
|
||||
const payload = err.response.data
|
||||
if (payload.error === "already_counted") {
|
||||
toast.error("This item has already been counted.")
|
||||
return
|
||||
}
|
||||
if (payload.error === "validation_failed") {
|
||||
toast.error("Validation failed. Please check your inputs.")
|
||||
return
|
||||
}
|
||||
if (payload.error === "not_found") {
|
||||
toast.error("Could not find that stock entry.")
|
||||
return
|
||||
}
|
||||
// Fallback to any message string
|
||||
toast.error(payload.message || "Unknown error occurred.")
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, a true “network” or unexpected error:
|
||||
toast.error("Failed to count stock. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
170
resources/js/Components/modals/EditRoomModal.tsx
Normal file
170
resources/js/Components/modals/EditRoomModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
347
resources/js/Components/modals/EditStockSections.tsx
Normal file
347
resources/js/Components/modals/EditStockSections.tsx
Normal 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
|
136
resources/js/Components/modals/MoveSectionModal.tsx
Normal file
136
resources/js/Components/modals/MoveSectionModal.tsx
Normal 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
|
82
resources/js/Components/modals/NewRoomModal.tsx
Normal file
82
resources/js/Components/modals/NewRoomModal.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
@ -1,48 +1,391 @@
|
||||
// components/modals/SetStockModal.tsx
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import {toast} from 'react-hot-toast'
|
||||
import {StockBatch, StockEntry, StockPosition, StockSection} from '@/types'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
|
||||
import {faCheckCircle, faTimesCircle} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
selectedBatch: StockBatch
|
||||
selectedSection: StockSection | null
|
||||
selectedPosition: StockPosition | null
|
||||
}
|
||||
|
||||
const SetStockModal: React.FC<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
342
resources/js/Components/modals/SetStockModalFromTemp.tsx
Normal file
342
resources/js/Components/modals/SetStockModalFromTemp.tsx
Normal 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
|
534
resources/js/Pages/Expedice.tsx
Normal file
534
resources/js/Pages/Expedice.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,16 +1,16 @@
|
||||
import React from 'react'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import axios from 'axios'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {Head, usePage} from '@inertiajs/react'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
faArrowLeft,
|
||||
faQuestionCircle,
|
||||
faBoxOpen,
|
||||
faClipboardList,
|
||||
faCubes,
|
||||
faPlus,
|
||||
faPlus, faBarcode,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { toast, Toaster } from 'react-hot-toast'
|
||||
import {toast, Toaster} from 'react-hot-toast'
|
||||
|
||||
import Tile from '../Components/Tile'
|
||||
import TileLayout from '../Components/TileLayout'
|
||||
@ -20,6 +20,16 @@ import ModalPDA from '../Components/ModalPDA'
|
||||
import SetStockModal from '../Components/modals/SetStockModal'
|
||||
import OtherReplacementModal from '../Components/modals/OtherReplacementModal'
|
||||
import CountStockModal from '../Components/modals/CountStockModal'
|
||||
import {Batch} from "@/interfaces/interfaces";
|
||||
import {StockBatch, StockPosition, StockSection} from "@/types";
|
||||
import BatchInfoModal from "@/Components/modals/BatchInfoModal";
|
||||
import {downloadBatchBarcode} from "@/functions/functions"
|
||||
import EditStockSections from "@/Components/modals/EditStockSections";
|
||||
import MoveSectionModal from "@/Components/modals/MoveSectionModal";
|
||||
import ChangeCountModal from "@/Components/modals/ChangeCountModal";
|
||||
|
||||
import error_scanner_sound from "@/sounds/error_scanner.mp3";
|
||||
import SetStockModalFromTemp from "@/Components/modals/SetStockModalFromTemp";
|
||||
|
||||
type Role = 'Expedice' | 'Skladnik'
|
||||
|
||||
@ -38,62 +48,103 @@ type TileConfig = {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const tilesConfig: Record<Role, Record<string, TileConfig[]>> = {
|
||||
|
||||
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: [
|
||||
{ title: 'Not Present', icon: faBoxOpen, onClick: () => toast('Not Present clicked') },
|
||||
{ title: 'Na pozici je jiny ovladac', icon: faBoxOpen, onClick: () => toast('Not Present clicked') },
|
||||
{title: 'Not Present', icon: faBoxOpen, onClick: () => toast('Not Present clicked')},
|
||||
{title: 'Na pozici je jiny ovladac', icon: faBoxOpen, onClick: () => toast('Not Present clicked')},
|
||||
{
|
||||
title: 'Present but Shouldn’t',
|
||||
icon: faClipboardList,
|
||||
onClick: async () => {
|
||||
// example direct axios call
|
||||
try {
|
||||
await axios.post('/api/presence-error', {}, { withCredentials: true })
|
||||
await axios.post('/api/presence-error', {}, {withCredentials: true})
|
||||
toast.success('Reported!')
|
||||
} catch {
|
||||
toast.error('Failed to report')
|
||||
}
|
||||
},
|
||||
},
|
||||
{ title: 'Other Replacement (na test/one time)', icon: faPlus, modalKey: 'otherReplacement' },
|
||||
{ title: 'Pridej vazbu na jiny ovladac', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Pridej docasnou vazbu (nez prijde jine zbozi, i casove omezene, nejnizsi priorita)', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Doplnit zbozi / dej vic kusu', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Report - chci zmenit pozici(bliz / dal od expedice)', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Pultovy prodej', icon: faPlus, onClick: () => toast('- naskenuju na webu / naskenuju na skladovem miste, obj. se udela automaticky, zakaznik si muze v rohu na PC vyplnit osobni udaje na special formulari - pak customera prida obsluha do obj') },
|
||||
{title: 'Other Replacement (na test/one time)', icon: faPlus, modalKey: 'otherReplacement'},
|
||||
{title: 'Pridej vazbu na jiny ovladac', icon: faPlus, onClick: () => toast('Batch Info clicked')},
|
||||
{
|
||||
title: 'Pridej docasnou vazbu (nez prijde jine zbozi, i casove omezene, nejnizsi priorita)',
|
||||
icon: faPlus,
|
||||
onClick: () => toast('Batch Info clicked')
|
||||
},
|
||||
{title: 'Doplnit zbozi / dej vic kusu', icon: faPlus, onClick: () => toast('Batch Info clicked')},
|
||||
{
|
||||
title: 'Report - chci zmenit pozici(bliz / dal od expedice)',
|
||||
icon: faPlus,
|
||||
onClick: () => toast('Batch Info clicked')
|
||||
},
|
||||
{
|
||||
title: 'Pultovy prodej',
|
||||
icon: faPlus,
|
||||
onClick: () => toast('- naskenuju na webu / naskenuju na skladovem miste, obj. se udela automaticky, zakaznik si muze v rohu na PC vyplnit osobni udaje na special formulari - pak customera prida obsluha do obj')
|
||||
},
|
||||
],
|
||||
labelScanned: [
|
||||
{ title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked')},
|
||||
]
|
||||
},
|
||||
Skladnik: {
|
||||
batchScan: [
|
||||
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Set Stock', icon: faCubes, modalKey: 'setStock' },
|
||||
{ title: 'Count Stock', icon: faPlus, modalKey: 'countStock' },
|
||||
{ title: 'Stitkovani (male stitky)', icon: faClipboardList, onClick: () => toast('Stitkovani (male stitky)') },
|
||||
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Tisk QR kod na krabice', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{title: 'Batch Info', icon: faClipboardList, modalKey: 'batchInfo'},
|
||||
{title: 'Set Stock', icon: faCubes, modalKey: 'setStock'},
|
||||
{title: 'Count Stock', icon: faPlus, modalKey: 'countStock'},
|
||||
{
|
||||
title: 'Stitkovani (male stitky)',
|
||||
icon: faClipboardList,
|
||||
onClick: () => toast('Stitkovani (male stitky)')
|
||||
},
|
||||
{
|
||||
title: 'Tisk QR kod na krabice',
|
||||
icon: faClipboardList,
|
||||
onClick: () => downloadBatchBarcode(selectedBatch?.id)
|
||||
},
|
||||
],
|
||||
stockScan: [
|
||||
{ title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: '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: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked')},
|
||||
{title: 'Set Stock', icon: faCubes, modalKey: 'setStockFromTemp'},
|
||||
{
|
||||
title: 'Zmena skladoveho mista (i presun jen casti kusu)',
|
||||
icon: faClipboardList,
|
||||
modalKey: 'moveStock'
|
||||
},
|
||||
{title: 'Uprava skladoveho mista (mene sekci, apod)', icon: faClipboardList, modalKey: 'editStock'},
|
||||
{title: 'Zmena poctu', icon: faClipboardList, modalKey: 'changeCount'},
|
||||
{
|
||||
title: 'Discard (odebrat ze skladoveho mista / posilame zpet)',
|
||||
icon: faClipboardList,
|
||||
onClick: () => toast('Batch Info clicked')
|
||||
},
|
||||
],
|
||||
others: [
|
||||
{ title: 'Nove skladove misto', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{title: 'Nove skladove misto', icon: faClipboardList, onClick: () => toast('Batch Info clicked')},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ModalKey = 'setStock' | 'otherReplacement' | 'countStock' | null
|
||||
|
||||
export default function PdaView() {
|
||||
const {
|
||||
auth: { user },
|
||||
auth: {user},
|
||||
} = usePage<{ auth: { user: { role: string } } }>().props
|
||||
|
||||
const [role, setRole] = React.useState<Role>(
|
||||
@ -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" />
|
||||
<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>
|
||||
<button className="btn btn-ghost">
|
||||
<FontAwesomeIcon icon={faQuestionCircle} /> Help
|
||||
<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 isn’t temporary, hide “Set Stock”
|
||||
if (action === 'stockScan' && !selectedPosition?.temporary) {
|
||||
tiles = tiles.filter(tile => tile.modalKey !== 'setStockFromTemp')
|
||||
}
|
||||
|
||||
// render the filtered list
|
||||
return tiles.map(({ title, icon, onClick, modalKey }) => (
|
||||
<Tile
|
||||
key={title}
|
||||
title={title}
|
||||
@ -169,17 +470,35 @@ export default function PdaView() {
|
||||
else if (onClick) onClick()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
})()}
|
||||
</TileLayout>
|
||||
|
||||
{/* Single Modal */}
|
||||
<ModalPDA isOpen={activeModal !== null} onClose={closeModal}>
|
||||
{activeModal === 'setStock' && <SetStockModal onClose={closeModal} />}
|
||||
{activeModal === 'otherReplacement' && <OtherReplacementModal onClose={closeModal} />}
|
||||
{activeModal === 'countStock' && <CountStockModal 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} 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" />
|
||||
<Toaster position="top-right"/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
94
resources/js/Pages/ShippingBatchList.tsx
Normal file
94
resources/js/Pages/ShippingBatchList.tsx
Normal 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
80
resources/js/Pages/StorageSetup.tsx
Normal file
80
resources/js/Pages/StorageSetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
resources/js/functions/functions.tsx
Normal file
33
resources/js/functions/functions.tsx
Normal 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')
|
||||
}
|
||||
}
|
313
resources/js/interfaces/interfaces.tsx
Normal file
313
resources/js/interfaces/interfaces.tsx
Normal 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;
|
||||
}
|
||||
|
BIN
resources/js/sounds/error_scanner.mp3
Normal file
BIN
resources/js/sounds/error_scanner.mp3
Normal file
Binary file not shown.
@ -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;
|
||||
};
|
||||
};
|
||||
|
@ -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">
|
||||
|
@ -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,16 +59,15 @@ Route::get('/stockStatusList', [StockEntryController::class, 'getStatusList']);
|
||||
|
||||
|
||||
|
||||
|
||||
// barcode scanner methods
|
||||
|
||||
Route::post('stockActions/itemPickup', [StockEntryController::class, 'itemPickup']);
|
||||
Route::post('stockActions/itemPickup', [StockEntryController::class, 'itemPickup']);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class, 'updateSectionCount']);
|
||||
Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class, 'updateSectionCount']);
|
||||
|
||||
|
||||
// expedice akce
|
||||
@ -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
|
||||
@ -90,8 +100,77 @@ Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class,
|
||||
|
||||
// rozbaleno produkty - prozkoumat
|
||||
|
||||
Route::post('/stockActions/{stockEntry}/status', [StockEntryStatusController::class, 'store']);
|
||||
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']);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
@ -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');
|
||||
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user