init commit
This commit is contained in:
parent
8f7e8488a5
commit
e637d26842
274
app/Http/Controllers/Api/StockBatchController.php
Normal file
274
app/Http/Controllers/Api/StockBatchController.php
Normal file
@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OriginCountry;
|
||||
use App\Models\StockBatch;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\StockPosition;
|
||||
use App\Models\PhysicalItem;
|
||||
use App\Models\StockSection;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class StockBatchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a paginated listing of stock entries.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = StockBatch::query()
|
||||
->with(['user', 'supplier', 'stockEntries.statusHistory.status', 'files']);
|
||||
|
||||
// Apply filters if provided
|
||||
if ($request->has('search')) {
|
||||
$search = $request->search;
|
||||
$query->whereHas('physicalItem', function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortField = $request->input('sort_field', 'updated_at');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
|
||||
// Paginate
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$page = $request->input('page', 1);
|
||||
|
||||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return response()->json([
|
||||
'data' => $entries->items(),
|
||||
'meta' => [
|
||||
'total' => $entries->total(),
|
||||
'per_page' => $entries->perPage(),
|
||||
'current_page' => $entries->currentPage(),
|
||||
'last_page' => $entries->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created stock batch, with multiple files.
|
||||
*/
|
||||
public function addData(Request $request)
|
||||
{
|
||||
try {
|
||||
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'supplier_id' => 'nullable|integer',
|
||||
'tracking_number' => 'nullable|string',
|
||||
'arrival_date' => 'nullable|date',
|
||||
'files.*' => 'file',
|
||||
'file_types' => 'array',
|
||||
'file_types.*' => 'in:invoice,label,other',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
// create the batch
|
||||
$batch = StockBatch::create([
|
||||
'user_id' => auth()->id() ?? 1,
|
||||
'supplier_id' => $request->input('supplier_id'),
|
||||
'tracking_number' => $request->input('tracking_number'),
|
||||
'arrival_date' => $request->input('arrival_date'),
|
||||
]);
|
||||
|
||||
// attach each uploaded file
|
||||
if ($request->hasFile('files')) {
|
||||
foreach ($request->file('files') as $i => $upload) {
|
||||
$batch->files()->create([
|
||||
'filename' => $upload->getClientOriginalName(),
|
||||
'file_data' => file_get_contents($upload->getRealPath()),
|
||||
'file_type' => $request->input("file_types.{$i}", 'other'),
|
||||
'user_id' => auth()->id() ?? 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Stock batch created successfully',
|
||||
'data' => $batch->load(['supplier', 'user', 'files', 'stockEntries']),
|
||||
], 201);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
return response()->json(['errors' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified stock batch, with its files & entries.
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
$batch = StockBatch::with(['user','supplier','files','stockEntries'])
|
||||
->findOrFail($id);
|
||||
|
||||
return response()->json(['data' => $batch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified stock batch and optionally add new files.
|
||||
*/
|
||||
public function updateData(Request $request, $id)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'supplier_id' => 'nullable|integer|exists:supplier,id',
|
||||
'tracking_number' => 'nullable|integer',
|
||||
'arrival_date' => 'nullable|date',
|
||||
'files.*' => 'file',
|
||||
'file_types' => 'array',
|
||||
'file_types.*' => 'in:invoice,label,other',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$batch = StockBatch::findOrFail($id);
|
||||
$batch->update($request->only(['supplier_id','tracking_number','arrival_date']) + [
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// if there are new files, attach them
|
||||
if ($request->hasFile('files')) {
|
||||
foreach ($request->file('files') as $i => $upload) {
|
||||
$batch->files()->create([
|
||||
'filename' => $upload->getClientOriginalName(),
|
||||
'file_data' => file_get_contents($upload->getRealPath()),
|
||||
'file_type' => $request->input("file_types.{$i}", 'other'),
|
||||
'user_id' => auth()->id() ?? 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Stock batch updated successfully',
|
||||
'data' => $batch->fresh(['supplier','user','files','stockEntries']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified stock batch (and its files).
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
$batch = StockBatch::with('files')->findOrFail($id);
|
||||
|
||||
// delete related files first if you need to clean up storage
|
||||
foreach ($batch->files as $file) {
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
$batch->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Stock batch deleted successfully',
|
||||
]);
|
||||
}
|
||||
/**
|
||||
* Get options for dropdown lists.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getOptions()
|
||||
{
|
||||
$stockPositions = StockSection::doesntHave('entries')
|
||||
->with('position.shelf.rack.line.room')
|
||||
->get()
|
||||
->map(function (StockSection $section) {
|
||||
$pos = $section->position;
|
||||
$shelf = $pos->shelf;
|
||||
$rack = $shelf->rack;
|
||||
$line = $rack->line;
|
||||
$room = $line->room;
|
||||
|
||||
return [
|
||||
'id' => $section->section_id,
|
||||
'name' => sprintf(
|
||||
'%s-%s-%s-%s-%s-%s',
|
||||
$room->room_symbol,
|
||||
$line->line_symbol,
|
||||
$rack->rack_symbol,
|
||||
$shelf->shelf_symbol,
|
||||
$pos->position_symbol,
|
||||
$section->section_symbol
|
||||
),
|
||||
];
|
||||
});
|
||||
|
||||
// Get suppliers from warehouse DB
|
||||
$suppliers = Supplier::select('id', 'name')->get();
|
||||
|
||||
// Get physical items from warehouse DB
|
||||
$countriesOrigin = OriginCountry::select('id', 'code as name')->get();
|
||||
|
||||
return response()->json([
|
||||
'stockPositions' => $stockPositions,
|
||||
'suppliers' => $suppliers,
|
||||
'countriesOrigin' => $countriesOrigin,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options for dropdown lists.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getItems(Request $request)
|
||||
{
|
||||
// Get physical items from warehouse DB
|
||||
$physicalItems = PhysicalItem::select('id', 'name')
|
||||
->where('name', 'like', '%' . $request->input('item_name', '') . '%')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'physicalItems' => $physicalItems,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options for dropdown lists.
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function getEntries(Request $request, $id)
|
||||
{
|
||||
// Get physical items from warehouse DB
|
||||
$stockEntries = StockEntry::with(['physicalItem', 'supplier', 'sections', 'statusHistory'])->where('stock_batch_id', $id)->get();
|
||||
|
||||
return response()->json([
|
||||
"data" => $stockEntries
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function addEntries(Request $request, $id)
|
||||
{
|
||||
// Get physical items from warehouse DB
|
||||
$stockEntries = StockEntry::whereIn('id', $request->get('ids'))->get();
|
||||
|
||||
foreach ($stockEntries as $entry) {
|
||||
$entry->update([
|
||||
'stock_batch_id' => $id,
|
||||
'on_the_way' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Batch entries updated successfully',
|
||||
'data' => $entry->fresh(),
|
||||
]);
|
||||
}
|
||||
}
|
@ -5,11 +5,16 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OriginCountry;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\StockEntrySection;
|
||||
use App\Models\StockEntryStatus;
|
||||
use App\Models\StockPosition;
|
||||
use App\Models\PhysicalItem;
|
||||
use App\Models\StockSection;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OwenIt\Auditing\Models\Audit;
|
||||
|
||||
class StockEntryController extends Controller
|
||||
{
|
||||
@ -22,7 +27,7 @@ class StockEntryController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = StockEntry::query()
|
||||
->with(['physicalItem', 'supplier', 'stockPosition', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
|
||||
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin']);
|
||||
|
||||
// Apply filters if provided
|
||||
if ($request->has('search')) {
|
||||
@ -54,15 +59,33 @@ class StockEntryController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created stock entry.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function fetchOnTheWay(Request $request)
|
||||
{
|
||||
$query = StockEntry::query()
|
||||
->with(['physicalItem', 'supplier', 'sections', 'stockBatch', 'physicalItem.manufacturer', 'countryOfOrigin'])->where('on_the_way', 1);
|
||||
|
||||
|
||||
// Paginate
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$page = $request->input('page', 1);
|
||||
|
||||
$entries = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return response()->json([
|
||||
'data' => $entries->items(),
|
||||
'meta' => [
|
||||
'total' => $entries->total(),
|
||||
'per_page' => $entries->perPage(),
|
||||
'current_page' => $entries->currentPage(),
|
||||
'last_page' => $entries->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function addData(Request $request)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
// build base rules
|
||||
$rules = [
|
||||
'physical_item_id' => 'required|integer|exists:vat_warehouse.physical_item,id',
|
||||
'supplier_id' => 'required|integer|exists:vat_warehouse.supplier,id',
|
||||
'count' => 'required|integer|min:0',
|
||||
@ -70,23 +93,70 @@ class StockEntryController extends Controller
|
||||
'bought' => 'nullable|date',
|
||||
'description' => 'nullable|string',
|
||||
'note' => 'nullable|string',
|
||||
'stock_position_id' => 'required|integer|exists:stock_positions,id',
|
||||
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
|
||||
'country_of_origin_id' => 'required|integer|exists:vat_warehouse.country_of_origin,id',
|
||||
'on_the_way' => 'boolean',
|
||||
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
|
||||
]);
|
||||
];
|
||||
|
||||
// condition for requiring section + count
|
||||
$needsSection = function() use ($request) {
|
||||
return ! $request->boolean('on_the_way')
|
||||
&& !is_null($request->input('stock_batch_id'));
|
||||
};
|
||||
|
||||
// add conditional rules
|
||||
$rules['stock_position_id'] = [
|
||||
Rule::requiredIf($needsSection),
|
||||
'integer',
|
||||
'exists:stock_section,section_id'
|
||||
];
|
||||
$rules['section_count'] = [
|
||||
Rule::requiredIf($needsSection),
|
||||
'integer',
|
||||
'min:1'
|
||||
];
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$entry = StockEntry::create($request->all() + [
|
||||
'created_by' => 1,
|
||||
// 1) create the main stock entry
|
||||
$entry = StockEntry::create($request->only([
|
||||
'physical_item_id',
|
||||
'supplier_id',
|
||||
'count',
|
||||
'price',
|
||||
'bought',
|
||||
'description',
|
||||
'note',
|
||||
'country_of_origin_id',
|
||||
'on_the_way',
|
||||
'stock_batch_id',
|
||||
]) + [
|
||||
'created_by' => auth()->id() ?? 1,
|
||||
]);
|
||||
|
||||
// 2) only attach to section pivot if needed
|
||||
if ($needsSection()) {
|
||||
$entry->sections()->attach(
|
||||
$request->input('stock_position_id'),
|
||||
['count' => $request->input('section_count')]
|
||||
);
|
||||
}
|
||||
|
||||
// 3) eager-load relations (including the full address hierarchy)
|
||||
$entry->load([
|
||||
'physicalItem',
|
||||
'supplier',
|
||||
'stockBatch',
|
||||
'sections.position.shelf.rack.line.room',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Stock entry created successfully',
|
||||
'data' => $entry->load(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']),
|
||||
'data' => $entry,
|
||||
], 201);
|
||||
}
|
||||
|
||||
@ -113,35 +183,120 @@ class StockEntryController extends Controller
|
||||
*/
|
||||
public function updateData(Request $request, $id)
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'physical_item_id' => 'integer|exists:vat_warehouse.physical_item,id',
|
||||
'supplier_id' => 'integer|exists:vat_warehouse.supplier,id',
|
||||
'count' => 'integer|min:0',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'bought' => 'nullable|date',
|
||||
'description' => 'nullable|string',
|
||||
'note' => 'nullable|string',
|
||||
'stock_position_id' => 'integer|exists:stock_positions,id',
|
||||
'country_of_origin_id' => 'integer|exists:vat_warehouse.country_of_origin,id',
|
||||
'on_the_way' => 'boolean',
|
||||
'stock_batch_id' => 'nullable|integer|exists:stock_batch,id',
|
||||
]);
|
||||
// 1) pull apart
|
||||
$entryForm = $request->input('entryForm', []);
|
||||
$sections = $request->input('sections', []);
|
||||
|
||||
// 2) validation rules as before…
|
||||
$rules = [
|
||||
'entryForm.physical_item_id' => ['sometimes','integer','exists:vat_warehouse.physical_item,id'],
|
||||
'entryForm.supplier_id' => ['sometimes','integer','exists:vat_warehouse.supplier,id'],
|
||||
'entryForm.count' => ['sometimes','integer','min:0'],
|
||||
'entryForm.price' => ['nullable','numeric','min:0'],
|
||||
'entryForm.bought' => ['nullable','date'],
|
||||
'entryForm.description' => ['nullable','string'],
|
||||
'entryForm.note' => ['nullable','string'],
|
||||
'entryForm.stock_batch_id' => ['nullable','integer','exists:stock_batch,id'],
|
||||
'entryForm.country_of_origin_id' => ['sometimes','integer','exists:vat_warehouse.country_of_origin,id'],
|
||||
'entryForm.on_the_way' => ['sometimes','boolean'],
|
||||
];
|
||||
|
||||
// determine if sections are required
|
||||
$needsSection = function() use ($request) {
|
||||
return ! $request->input('entryForm.on_the_way', false)
|
||||
&& ! is_null($request->input('entryForm.stock_batch_id'));
|
||||
};
|
||||
|
||||
// validate sections array if needed
|
||||
$rules['sections'] = [Rule::requiredIf($needsSection), 'array'];
|
||||
$rules['sections.*.stock_position_id'] = [Rule::requiredIf($needsSection), 'integer', 'exists:stock_section,section_id'];
|
||||
$rules['sections.*.count'] = [Rule::requiredIf($needsSection), 'integer', 'min:1'];
|
||||
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['errors' => $validator->errors()], 422);
|
||||
return response()->json(['errors'=>$validator->errors()], 422);
|
||||
}
|
||||
|
||||
$entry = StockEntry::findOrFail($id);
|
||||
$entry->update($request->all() + [
|
||||
'updated_by' => 1,
|
||||
/** @var StockEntry $entry */
|
||||
$entry = StockEntry::with('sections')->findOrFail($id);
|
||||
|
||||
// 3) Update the main row (this is auto‐audited by OwenIt)
|
||||
$entry->update(array_merge(
|
||||
array_filter($entryForm, fn($v) => !is_null($v)),
|
||||
['updated_by'=> auth()->id() ?? 1]
|
||||
));
|
||||
|
||||
// 4) Manual pivot‐audit:
|
||||
// a) snapshot what was there
|
||||
$oldPivots = $entry->sections
|
||||
->pluck('pivot.count','pivot.section_id')
|
||||
->toArray(); // [ section_id => oldCount, … ]
|
||||
|
||||
// b) perform the sync
|
||||
$syncData = [];
|
||||
foreach ($sections as $sec) {
|
||||
if (!empty($sec['stock_position_id']) && !empty($sec['count'])) {
|
||||
$syncData[(int)$sec['stock_position_id']] = ['count'=>(int)$sec['count']];
|
||||
}
|
||||
}
|
||||
// $entry->sections()->sync($syncData);
|
||||
// b) perform the sync, but don’t let the AuditableObserver fire here…
|
||||
StockEntrySection::withoutAuditing(function() use ($entry, $syncData) {
|
||||
$entry->sections()->sync($syncData);
|
||||
});
|
||||
|
||||
// c) now compare old↔new and write audit rows
|
||||
$userId = auth()->id() ?? null;
|
||||
foreach ($syncData as $sectionId => $pivotData) {
|
||||
$newCount = $pivotData['count'];
|
||||
if (! array_key_exists($sectionId, $oldPivots)) {
|
||||
// *** attached ***
|
||||
Audit::create([
|
||||
'user_id' => $userId,
|
||||
'auditable_type'=> StockEntrySection::class,
|
||||
'auditable_id' => $entry->id, // pivot has no single PK; we use entry ID
|
||||
'event' => 'created',
|
||||
'old_values' => [],
|
||||
'new_values' => ['section_id'=>$sectionId,'count'=>$newCount],
|
||||
]);
|
||||
} elseif ($oldPivots[$sectionId] !== $newCount) {
|
||||
// *** updated ***
|
||||
Audit::create([
|
||||
'user_id' => $userId,
|
||||
'auditable_type'=> StockEntrySection::class,
|
||||
'auditable_id' => $entry->id,
|
||||
'event' => 'updated',
|
||||
'old_values' => ['count'=>$oldPivots[$sectionId]],
|
||||
'new_values' => ['count'=>$newCount],
|
||||
]);
|
||||
}
|
||||
}
|
||||
// d) any removed?
|
||||
foreach (array_diff_key($oldPivots, $syncData) as $sectionId => $oldCount) {
|
||||
Audit::create([
|
||||
'user_id' => $userId,
|
||||
'auditable_type'=> StockEntrySection::class,
|
||||
'auditable_id' => $entry->id,
|
||||
'event' => 'deleted',
|
||||
'old_values' => ['section_id'=>$sectionId,'count'=>$oldCount],
|
||||
'new_values' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// 5) reload and return
|
||||
$entry->load([
|
||||
'physicalItem',
|
||||
'supplier',
|
||||
'stockBatch',
|
||||
'sections.position.shelf.rack.line.room',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Stock entry updated successfully',
|
||||
'data' => $entry->fresh(['physicalItem', 'supplier', 'stockPosition', 'stockBatch']),
|
||||
'message'=>'Stock entry updated successfully',
|
||||
'data' =>$entry,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified stock entry.
|
||||
*
|
||||
@ -165,11 +320,29 @@ class StockEntryController extends Controller
|
||||
*/
|
||||
public function getOptions()
|
||||
{
|
||||
$stockPositions = StockPosition::select('id', 'line', 'rack', 'shelf', 'position')->get()
|
||||
->map(function($position) {
|
||||
|
||||
$stockPositions = StockSection::with('position.shelf.rack.line.room')
|
||||
->get()
|
||||
->map(function (StockSection $section) {
|
||||
$pos = $section->position;
|
||||
$shelf = $pos->shelf;
|
||||
$rack = $shelf->rack;
|
||||
$line = $rack->line;
|
||||
$room = $line->room;
|
||||
|
||||
return [
|
||||
'id' => $position->id,
|
||||
'name' => $position->line . '-' . $position->rack . '-' . $position->shelf . '-' . $position->position
|
||||
'id' => $section->section_id,
|
||||
'name' => sprintf(
|
||||
'%s-%s-%s-%s-%s-%s',
|
||||
$room->room_symbol,
|
||||
$line->line_symbol,
|
||||
$rack->rack_symbol,
|
||||
$shelf->shelf_symbol,
|
||||
$pos->position_symbol,
|
||||
$section->section_symbol
|
||||
),
|
||||
'capacity' => $section->capacity,
|
||||
'occupied' => $section->occupied()
|
||||
];
|
||||
});
|
||||
|
||||
@ -195,11 +368,41 @@ class StockEntryController extends Controller
|
||||
{
|
||||
// Get physical items from warehouse DB
|
||||
$physicalItems = PhysicalItem::select('id', 'name')
|
||||
->where('name', 'like', '%' . $request->input('item_name', '') . '%')
|
||||
->where('name', 'like', '%' . $request->input('item_name', '') . '%')->limit(100)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'physicalItems' => $physicalItems,
|
||||
]);
|
||||
}
|
||||
|
||||
public function audit(Request $request, $id)
|
||||
{
|
||||
// 1) Load the entry (so we can get its audits)
|
||||
$entry = StockEntry::findOrFail($id);
|
||||
|
||||
// 2) Get audits for the entry model itself
|
||||
$entryAudits = $entry->audits()->orderBy('created_at')->get();
|
||||
|
||||
// 3) Get audits for all pivot changes you logged under the pivot model
|
||||
$pivotAudits = Audit::query()
|
||||
->where('auditable_type', StockEntrySection::class)
|
||||
->where('auditable_id', $id) // we used entry->id when creating these
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'entry_audits' => $entryAudits,
|
||||
'pivot_audits' => $pivotAudits,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getStatusList(Request $request)
|
||||
{
|
||||
$entry = StockEntryStatus::all();
|
||||
|
||||
return response()->json([
|
||||
'statuses' => $entry,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
43
app/Http/Controllers/Api/StockEntryStatusController.php
Normal file
43
app/Http/Controllers/Api/StockEntryStatusController.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\StockEntryStatusHistory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StockEntryStatusController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a new status history record for a given stock entry.
|
||||
*/
|
||||
public function store(Request $request, StockEntry $stockEntry)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => [
|
||||
'required',
|
||||
'integer',
|
||||
// ensure the status ID exists in your statuses table
|
||||
Rule::exists('stock_entries_status', 'id'),
|
||||
],
|
||||
'count' => ['nullable', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
// Create the history record
|
||||
$history = StockEntryStatusHistory::create([
|
||||
'stock_entries_id' => $stockEntry->id,
|
||||
'stock_entries_status_id' => $data['status'],
|
||||
'status_note' => isset($data['count'])
|
||||
? "Count: {$data['count']}"
|
||||
: null,
|
||||
'section_id' => null
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Status updated successfully.',
|
||||
'history' => $history,
|
||||
], 201);
|
||||
}
|
||||
}
|
65
app/Http/Controllers/Api/StockHandleExpediceController.php
Normal file
65
app/Http/Controllers/Api/StockHandleExpediceController.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\OriginCountry;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\StockPosition;
|
||||
use App\Models\PhysicalItem;
|
||||
use App\Models\StockSection;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StockHandleExpediceController extends Controller
|
||||
{
|
||||
public function updateSectionCount(Request $request)
|
||||
{
|
||||
|
||||
$data = $request->validate([
|
||||
'count' => ['required', 'integer', 'min:0'],
|
||||
'section_id' => ['required', 'integer'],
|
||||
// 'mapping_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$entry = StockEntry::with('sections')
|
||||
->whereHas('sections', function($q) use ($data) {
|
||||
$q->where('section_id', $data['section_id']);
|
||||
})
|
||||
->first();
|
||||
|
||||
// 1) Update the pivot table count for this section
|
||||
$entry->sections()->updateExistingPivot($data['section_id'], [
|
||||
'count' => $data['count'],
|
||||
]);
|
||||
|
||||
// 2) Recalculate the total across all sections and save it on the entry
|
||||
// (assuming you want `stock_entries.count` to always equal the sum of its section counts)
|
||||
$total = $entry->sections()
|
||||
->get() // pull fresh pivot data
|
||||
->sum(fn($sec) => $sec->pivot->count);
|
||||
|
||||
$entry->count = $total;
|
||||
$entry->save();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Section count and entry total updated',
|
||||
'data' => $entry->load('sections'),
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
public function getItems(Request $request)
|
||||
{
|
||||
// Get physical items from warehouse DB
|
||||
$physicalItems = PhysicalItem::select('id', 'name')
|
||||
->where('name', 'like', '%' . $request->input('item_name', '') . '%')->limit(100)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'physicalItems' => $physicalItems,
|
||||
]);
|
||||
}
|
||||
}
|
23
app/Http/Controllers/Api/SupplierController.php
Normal file
23
app/Http/Controllers/Api/SupplierController.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
use App\Models\OriginCountry;
|
||||
use App\Models\Supplier;
|
||||
|
||||
class SupplierController
|
||||
{
|
||||
|
||||
public function getOptions()
|
||||
{
|
||||
$suppliers = Supplier::select('id', 'name')->get();
|
||||
|
||||
// Get physical items from warehouse DB
|
||||
$countriesOrigin = OriginCountry::select('id', 'code as name')->get();
|
||||
|
||||
return response()->json([
|
||||
'suppliers' => $suppliers,
|
||||
'countriesOrigin' => $countriesOrigin,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
@ -2,35 +2,44 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class StockBatch extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'stock_batch';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = [
|
||||
'supplier_id',
|
||||
'user_id',
|
||||
'supplier_id',
|
||||
'tracking_number',
|
||||
'arrival_date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the supplier associated with the stock batch.
|
||||
* The attributes that should be cast to native types.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class, 'supplier_id')->on('vat_warehouse');
|
||||
}
|
||||
protected $casts = [
|
||||
'arrival_date' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user associated with the stock batch.
|
||||
* Get the user that owns the stock batch.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
@ -38,10 +47,33 @@ class StockBatch extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stock entries for this batch.
|
||||
* Get the supplier for the stock batch.
|
||||
*/
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function stockEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockEntry::class);
|
||||
}
|
||||
|
||||
public function files()
|
||||
{
|
||||
return $this->hasMany(StockBatchFile::class)->select([
|
||||
'id',
|
||||
'filename',
|
||||
'file_type',
|
||||
'stock_batch_id',
|
||||
'user_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
|
||||
public function statusHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockBatchStatusHistory::class, 'stock_batch_id');
|
||||
}
|
||||
}
|
||||
|
34
app/Models/StockBatchFile.php
Normal file
34
app/Models/StockBatchFile.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StockBatchFile extends Model
|
||||
{
|
||||
protected $table = 'stock_batch_files';
|
||||
|
||||
protected $fillable = [
|
||||
'filename',
|
||||
'file_data',
|
||||
'file_type',
|
||||
'stock_batch_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function stockBatch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockBatch::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
26
app/Models/StockBatchStatus.php
Normal file
26
app/Models/StockBatchStatus.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class StockBatchStatus extends Model
|
||||
{
|
||||
protected $table = 'stock_batch_status';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function history(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockBatchStatusHistory::class, 'stock_batch_status_id');
|
||||
}
|
||||
}
|
32
app/Models/StockBatchStatusHistory.php
Normal file
32
app/Models/StockBatchStatusHistory.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StockBatchStatusHistory extends Model
|
||||
{
|
||||
protected $table = 'stock_batch_status_history';
|
||||
|
||||
protected $fillable = [
|
||||
'stock_batch_id',
|
||||
'stock_batch_status_id',
|
||||
'status_note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function stockBatch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockBatch::class);
|
||||
}
|
||||
|
||||
public function status(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockBatchStatus::class, 'stock_batch_status_id');
|
||||
}
|
||||
}
|
32
app/Models/StockEntries2Section.php
Normal file
32
app/Models/StockEntries2Section.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockEntries2Section extends Model
|
||||
{
|
||||
protected $table = 'stock_entries2section';
|
||||
public $incrementing = false;
|
||||
public $timestamps = true;
|
||||
|
||||
// Composite PKs aren’t natively supported by Eloquent;
|
||||
// you can override getKey() / setKey*() if needed, or treat this as a pure pivot.
|
||||
protected $primaryKey = null;
|
||||
|
||||
protected $fillable = [
|
||||
'entry_id',
|
||||
'section_id',
|
||||
'count',
|
||||
];
|
||||
|
||||
public function section()
|
||||
{
|
||||
return $this->belongsTo(StockSection::class, 'section_id', 'section_id');
|
||||
}
|
||||
|
||||
// assuming you have an App\Models\StockEntry model
|
||||
public function entry()
|
||||
{
|
||||
return $this->belongsTo(StockEntry::class, 'entry_id', 'id');
|
||||
}
|
||||
}
|
@ -6,12 +6,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
class StockEntry extends Model
|
||||
|
||||
class StockEntry extends Model implements AuditableContract
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, Auditable;
|
||||
|
||||
protected $table = 'stock_entry';
|
||||
protected $table = 'stock_entries';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@ -22,11 +26,11 @@ class StockEntry extends Model
|
||||
'physical_item_id',
|
||||
'supplier_id',
|
||||
'count',
|
||||
'original_count',
|
||||
'price',
|
||||
'bought',
|
||||
'description',
|
||||
'note',
|
||||
'stock_position_id',
|
||||
'country_of_origin_id',
|
||||
'on_the_way',
|
||||
'stock_batch_id',
|
||||
@ -60,14 +64,6 @@ class StockEntry extends Model
|
||||
return $this->belongsTo(Supplier::class, 'supplier_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stock position associated with the stock entry.
|
||||
*/
|
||||
public function stockPosition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockPosition::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stock batch associated with the stock entry.
|
||||
*/
|
||||
@ -76,6 +72,11 @@ class StockEntry extends Model
|
||||
return $this->belongsTo(StockBatch::class);
|
||||
}
|
||||
|
||||
public function statusHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockEntryStatusHistory::class, 'stock_entries_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes for this stock entry.
|
||||
*/
|
||||
@ -93,4 +94,51 @@ class StockEntry extends Model
|
||||
{
|
||||
return $this->belongsTo(OriginCountry::class, 'country_of_origin_id');
|
||||
}
|
||||
|
||||
public function sections(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
StockSection::class,
|
||||
'stock_entries2section',
|
||||
'entry_id',
|
||||
'section_id'
|
||||
)
|
||||
->using(StockEntrySection::class)
|
||||
->withPivot('count')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Return each storage “address” string:
|
||||
* Room-Line-Rack-Shelf-Position-Section
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function storageAddresses(): array
|
||||
{
|
||||
// eager-load the whole hierarchy if not already
|
||||
$this->loadMissing('sections.position.shelf.rack.line.room');
|
||||
|
||||
return $this->sections
|
||||
->map(function (StockSection $section) {
|
||||
$pos = $section->position;
|
||||
$shelf = $pos->shelf;
|
||||
$rack = $shelf->rack;
|
||||
$line = $rack->line;
|
||||
$room = $line->room;
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s-%s',
|
||||
$room->room_symbol,
|
||||
$line->line_symbol,
|
||||
$rack->rack_symbol,
|
||||
$shelf->shelf_symbol,
|
||||
$pos->position_symbol,
|
||||
$section->section_symbol
|
||||
);
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
15
app/Models/StockEntrySection.php
Normal file
15
app/Models/StockEntrySection.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
|
||||
class StockEntrySection extends Pivot implements AuditableContract
|
||||
{
|
||||
use Auditable;
|
||||
protected $table = 'stock_entries2section';
|
||||
public $incrementing = false; // composite PK
|
||||
protected $primaryKey = null;
|
||||
}
|
42
app/Models/StockEntryStatus.php
Normal file
42
app/Models/StockEntryStatus.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class StockEntryStatus extends Model
|
||||
{
|
||||
protected $table = 'stock_entries_status';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const NEW_GOODS_STOCKED = 1;
|
||||
public const NEW_GOODS_COUNTED = 2;
|
||||
public const NEW_GOODS_ARRIVED = 3;
|
||||
public const BATCH_CREATED = 4;
|
||||
public const MOVED_TO_NEW_POSITION = 5;
|
||||
public const NEW_GOODS_DAMAGED = 6;
|
||||
public const NEW_GOODS_MISSING = 7;
|
||||
public const NEW_GOODS_SURPLUS = 8;
|
||||
public const STOCK_MISSING = 9;
|
||||
|
||||
public const STOCK_DISCARDED = 10;
|
||||
public const STOCK_RETURNED = 11;
|
||||
|
||||
|
||||
#naskladneno, spocitano, presunuto na XY, poskozeno, chybi, nadbyva
|
||||
|
||||
public function history(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockEntryStatusHistory::class, 'stock_entries_status_id');
|
||||
}
|
||||
}
|
38
app/Models/StockEntryStatusHistory.php
Normal file
38
app/Models/StockEntryStatusHistory.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StockEntryStatusHistory extends Model
|
||||
{
|
||||
protected $table = 'stock_entries_status_history';
|
||||
|
||||
protected $fillable = [
|
||||
'stock_entries_id',
|
||||
'stock_entries_status_id',
|
||||
'status_note',
|
||||
'section_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function entry(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockEntry::class, 'stock_entries_id');
|
||||
}
|
||||
|
||||
public function status(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockEntryStatus::class, 'stock_entries_status_id');
|
||||
}
|
||||
|
||||
public function section(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockSection::class, 'section_id');
|
||||
}
|
||||
}
|
27
app/Models/StockLine.php
Normal file
27
app/Models/StockLine.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockLine extends Model
|
||||
{
|
||||
protected $table = 'stock_line';
|
||||
protected $primaryKey = 'line_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'line_symbol',
|
||||
'line_name',
|
||||
'room_id',
|
||||
];
|
||||
|
||||
public function room()
|
||||
{
|
||||
return $this->belongsTo(StockRoom::class, 'room_id', 'room_id');
|
||||
}
|
||||
|
||||
public function racks()
|
||||
{
|
||||
return $this->hasMany(StockRack::class, 'line_id', 'line_id');
|
||||
}
|
||||
}
|
@ -1,47 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockPosition extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $table = 'stock_position';
|
||||
protected $primaryKey = 'position_id';
|
||||
public $timestamps = true;
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'stock_positions';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'line',
|
||||
'rack',
|
||||
'shelf',
|
||||
'position',
|
||||
'position_symbol',
|
||||
'position_name',
|
||||
'shelf_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the full position string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getFullPositionAttribute(): string
|
||||
public function shelf()
|
||||
{
|
||||
return "{$this->line}-{$this->rack}-{$this->shelf}-{$this->position}";
|
||||
return $this->belongsTo(StockShelf::class, 'shelf_id', 'shelf_id');
|
||||
}
|
||||
|
||||
public function sections()
|
||||
{
|
||||
return $this->hasMany(StockSection::class, 'position_id', 'position_id');
|
||||
}
|
||||
}
|
||||
|
27
app/Models/StockRack.php
Normal file
27
app/Models/StockRack.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockRack extends Model
|
||||
{
|
||||
protected $table = 'stock_rack';
|
||||
protected $primaryKey = 'rack_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'rack_symbol',
|
||||
'rack_name',
|
||||
'line_id',
|
||||
];
|
||||
|
||||
public function line()
|
||||
{
|
||||
return $this->belongsTo(StockLine::class, 'line_id', 'line_id');
|
||||
}
|
||||
|
||||
public function shelves()
|
||||
{
|
||||
return $this->hasMany(StockShelf::class, 'rack_id', 'rack_id');
|
||||
}
|
||||
}
|
21
app/Models/StockRoom.php
Normal file
21
app/Models/StockRoom.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockRoom extends Model
|
||||
{
|
||||
protected $table = 'stock_room';
|
||||
protected $primaryKey = 'room_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'room_symbol',
|
||||
'room_name',
|
||||
];
|
||||
|
||||
public function lines()
|
||||
{
|
||||
return $this->hasMany(StockLine::class, 'room_id', 'room_id');
|
||||
}
|
||||
}
|
43
app/Models/StockSection.php
Normal file
43
app/Models/StockSection.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockSection extends Model
|
||||
{
|
||||
protected $table = 'stock_section';
|
||||
protected $primaryKey = 'section_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'section_symbol',
|
||||
'section_name',
|
||||
'position_id',
|
||||
'capacity',
|
||||
'retrievable'
|
||||
];
|
||||
|
||||
public function position()
|
||||
{
|
||||
return $this->belongsTo(StockPosition::class, 'position_id', 'position_id');
|
||||
}
|
||||
|
||||
// If you have a StockEntry model, you can set up a many-to-many via the pivot:
|
||||
public function entries()
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
StockEntry::class, // your entry model
|
||||
'stock_entries2section', // pivot table
|
||||
'section_id', // this modelʼs FK on pivot
|
||||
'entry_id' // other modelʼs FK on pivot
|
||||
)
|
||||
->withPivot('count')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function occupied(): bool
|
||||
{
|
||||
return $this->entries()->exists();
|
||||
}
|
||||
|
||||
}
|
27
app/Models/StockShelf.php
Normal file
27
app/Models/StockShelf.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockShelf extends Model
|
||||
{
|
||||
protected $table = 'stock_shelf';
|
||||
protected $primaryKey = 'shelf_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'shelf_symbol',
|
||||
'shelf_name',
|
||||
'rack_id',
|
||||
];
|
||||
|
||||
public function rack()
|
||||
{
|
||||
return $this->belongsTo(StockRack::class, 'rack_id', 'rack_id');
|
||||
}
|
||||
|
||||
public function positions()
|
||||
{
|
||||
return $this->hasMany(StockPosition::class, 'shelf_id', 'shelf_id');
|
||||
}
|
||||
}
|
@ -15,6 +15,7 @@
|
||||
"laravel/jetstream": "^5.3",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"owen-it/laravel-auditing": "^14.0",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
86
composer.lock
generated
86
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": "6b26c4fac677e9b104c9fd2bcb97d5a4",
|
||||
"content-hash": "87a6c86e9f85c22d4b6e67d73bb8823d",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -2941,6 +2941,90 @@
|
||||
],
|
||||
"time": "2025-05-08T08:14:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "owen-it/laravel-auditing",
|
||||
"version": "v14.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/owen-it/laravel-auditing.git",
|
||||
"reference": "f92602d1b3f53df29ddd577290e9d735ea707c53"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/f92602d1b3f53df29ddd577290e9d735ea707c53",
|
||||
"reference": "f92602d1b3f53df29ddd577290e9d735ea707c53",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0|^12.0",
|
||||
"illuminate/database": "^11.0|^12.0",
|
||||
"illuminate/filesystem": "^11.0|^12.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5.1",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"type": "package",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"OwenIt\\Auditing\\AuditingServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "v14-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OwenIt\\Auditing\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antério Vieira",
|
||||
"email": "anteriovieira@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Raphael França",
|
||||
"email": "raphaelfrancabsb@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Morten D. Hansen",
|
||||
"email": "morten@visia.dk"
|
||||
}
|
||||
],
|
||||
"description": "Audit changes of your Eloquent models in Laravel",
|
||||
"homepage": "https://laravel-auditing.com",
|
||||
"keywords": [
|
||||
"Accountability",
|
||||
"Audit",
|
||||
"auditing",
|
||||
"changes",
|
||||
"eloquent",
|
||||
"history",
|
||||
"laravel",
|
||||
"log",
|
||||
"logging",
|
||||
"lumen",
|
||||
"observer",
|
||||
"record",
|
||||
"revision",
|
||||
"tracking"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/owen-it/laravel-auditing/issues",
|
||||
"source": "https://github.com/owen-it/laravel-auditing"
|
||||
},
|
||||
"time": "2025-02-26T16:40:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.0.0",
|
||||
|
198
config/audit.php
Normal file
198
config/audit.php
Normal file
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'enabled' => env('AUDITING_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Implementation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define which Audit model implementation should be used.
|
||||
|
|
||||
*/
|
||||
|
||||
'implementation' => OwenIt\Auditing\Models\Audit::class,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Morph prefix & Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the morph prefix and authentication guards for the User resolver.
|
||||
|
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'user',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
],
|
||||
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Resolvers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the IP Address, User Agent and URL resolver implementations.
|
||||
|
|
||||
*/
|
||||
'resolvers' => [
|
||||
'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class,
|
||||
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class,
|
||||
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Events
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The Eloquent events that trigger an Audit.
|
||||
|
|
||||
*/
|
||||
|
||||
'events' => [
|
||||
'created',
|
||||
'updated',
|
||||
'deleted',
|
||||
'restored',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Strict Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Enable the strict mode when auditing?
|
||||
|
|
||||
*/
|
||||
|
||||
'strict' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global exclude
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Have something you always want to exclude by default? - add it here.
|
||||
| Note that this is overwritten (not merged) with local exclude
|
||||
|
|
||||
*/
|
||||
|
||||
'exclude' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Empty Values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should Audit records be stored when the recorded old_values & new_values
|
||||
| are both empty?
|
||||
|
|
||||
| Some events may be empty on purpose. Use allowed_empty_values to exclude
|
||||
| those from the empty values check. For example when auditing
|
||||
| model retrieved events which will never have new and old values.
|
||||
|
|
||||
|
|
||||
*/
|
||||
|
||||
'empty_values' => true,
|
||||
'allowed_empty_values' => [
|
||||
'retrieved',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed Array Values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should the array values be audited?
|
||||
|
|
||||
| By default, array values are not allowed. This is to prevent performance
|
||||
| issues when storing large amounts of data. You can override this by
|
||||
| setting allow_array_values to true.
|
||||
*/
|
||||
'allowed_array_values' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Timestamps
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
||||
|
|
||||
*/
|
||||
|
||||
'timestamps' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify a threshold for the amount of Audit records a model can have.
|
||||
| Zero means no limit.
|
||||
|
|
||||
*/
|
||||
|
||||
'threshold' => 0,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default audit driver used to keep track of changes.
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => 'database',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Driver Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Available audit drivers and respective configurations.
|
||||
|
|
||||
*/
|
||||
|
||||
'drivers' => [
|
||||
'database' => [
|
||||
'table' => 'audits',
|
||||
'connection' => null,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Queue Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Available audit queue configurations.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => [
|
||||
'enable' => false,
|
||||
'connection' => 'sync',
|
||||
'queue' => 'default',
|
||||
'delay' => 0,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Console
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether console events should be audited (eg. php artisan db:seed).
|
||||
|
|
||||
*/
|
||||
|
||||
'console' => false,
|
||||
];
|
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||
$table = config('audit.drivers.database.table', 'audits');
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table) {
|
||||
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
|
||||
$table->bigIncrements('id');
|
||||
$table->string($morphPrefix . '_type')->nullable();
|
||||
$table->unsignedBigInteger($morphPrefix . '_id')->nullable();
|
||||
$table->string('event');
|
||||
$table->morphs('auditable');
|
||||
$table->text('old_values')->nullable();
|
||||
$table->text('new_values')->nullable();
|
||||
$table->text('url')->nullable();
|
||||
$table->ipAddress('ip_address')->nullable();
|
||||
$table->string('user_agent', 1023)->nullable();
|
||||
$table->string('tags')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index([$morphPrefix . '_id', $morphPrefix . '_type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||
$table = config('audit.drivers.database.table', 'audits');
|
||||
|
||||
Schema::connection($connection)->drop($table);
|
||||
}
|
||||
};
|
64
database/seeders/StockLocationSeeder.php
Normal file
64
database/seeders/StockLocationSeeder.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
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 StockLocationSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
$rooms = [
|
||||
['room_name' => 'Sklad', 'room_symbol' => 'W'],
|
||||
['room_name' => 'Sklep - prodejna', 'room_symbol' => 'SKLPR'],
|
||||
['room_name' => 'Sklep - main', 'room_symbol' => 'SKLM'],
|
||||
];
|
||||
|
||||
foreach ($rooms as $roomData) {
|
||||
// 1) Create Room
|
||||
$room = StockRoom::create($roomData);
|
||||
|
||||
// 2) Create Line “A”
|
||||
$line = StockLine::create([
|
||||
'line_name' => 'A',
|
||||
'line_symbol' => 'A',
|
||||
'room_id' => $room->room_id,
|
||||
]);
|
||||
|
||||
// 3) Create Rack “1”
|
||||
$rack = StockRack::create([
|
||||
'rack_name' => '1',
|
||||
'rack_symbol' => '1',
|
||||
'line_id' => $line->line_id,
|
||||
]);
|
||||
|
||||
// 4) Create Shelf “1”
|
||||
$shelf = StockShelf::create([
|
||||
'shelf_name' => '1',
|
||||
'shelf_symbol' => '1',
|
||||
'rack_id' => $rack->rack_id,
|
||||
]);
|
||||
|
||||
// 5) Create Position “1”
|
||||
$position = StockPosition::create([
|
||||
'position_name' => '1',
|
||||
'position_symbol' => '1',
|
||||
'shelf_id' => $shelf->shelf_id,
|
||||
]);
|
||||
|
||||
// 6) (Optional) Create Section “1”
|
||||
StockSection::create([
|
||||
'section_name' => '1',
|
||||
'section_symbol' => '1',
|
||||
'capacity' => '100',
|
||||
'position_id' => $position->position_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
@ -9,80 +9,195 @@ create table stock_handling
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
);
|
||||
|
||||
create table stock_positions
|
||||
-- 1) Room: the top‐level container
|
||||
CREATE TABLE stock_room (
|
||||
room_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
room_symbol VARCHAR(15) NOT NULL,
|
||||
room_name VARCHAR(32) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NULL ON UPDATE NOW()
|
||||
);
|
||||
|
||||
-- 2) Line: belongs to a room
|
||||
CREATE TABLE stock_line (
|
||||
line_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
line_symbol VARCHAR(5) NOT NULL,
|
||||
line_name VARCHAR(32) DEFAULT NULL,
|
||||
room_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NULL ON UPDATE NOW(),
|
||||
FOREIGN KEY (room_id) REFERENCES stock_room(room_id)
|
||||
);
|
||||
|
||||
-- 3) Rack: belongs to a line
|
||||
CREATE TABLE stock_rack (
|
||||
rack_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
rack_symbol VARCHAR(5) NOT NULL,
|
||||
rack_name VARCHAR(32) DEFAULT NULL,
|
||||
line_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NULL ON UPDATE NOW(),
|
||||
FOREIGN KEY (line_id) REFERENCES stock_line(line_id)
|
||||
);
|
||||
|
||||
-- 4) Shelf: belongs to a rack
|
||||
CREATE TABLE stock_shelf (
|
||||
shelf_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
shelf_symbol VARCHAR(5) NOT NULL,
|
||||
shelf_name VARCHAR(32) DEFAULT NULL,
|
||||
rack_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NULL ON UPDATE NOW(),
|
||||
FOREIGN KEY (rack_id) REFERENCES stock_rack(rack_id)
|
||||
);
|
||||
|
||||
CREATE TABLE stock_position (
|
||||
position_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
position_symbol VARCHAR(5) NOT NULL,
|
||||
position_name VARCHAR(32) DEFAULT NULL,
|
||||
shelf_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NULL ON UPDATE NOW(),
|
||||
FOREIGN KEY (shelf_id) REFERENCES stock_shelf(shelf_id)
|
||||
);
|
||||
|
||||
CREATE TABLE stock_section (
|
||||
section_id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
section_symbol VARCHAR(5) NOT NULL,
|
||||
section_name VARCHAR(32) DEFAULT NULL,
|
||||
position_id INT NOT NULL,
|
||||
capacity int not null,
|
||||
retrievable bool default true,
|
||||
created_at DATETIME NOT NULL DEFAULT NOW(),
|
||||
updated_at DATETIME NULL ON UPDATE NOW(),
|
||||
FOREIGN KEY (position_id) REFERENCES stock_position(position_id)
|
||||
);
|
||||
|
||||
|
||||
|
||||
create table stock_entries2section(
|
||||
entry_id int not null,
|
||||
section_id int not null,
|
||||
count int not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
PRIMARY KEY (entry_id, section_id)
|
||||
);
|
||||
|
||||
create table stock_batch
|
||||
(
|
||||
id int not null auto_increment primary key,
|
||||
line varchar(5) not null,
|
||||
rack varchar(5) not null,
|
||||
shelf varchar(5) not null,
|
||||
position varchar(5) not null,
|
||||
id int primary key auto_increment,
|
||||
user_id bigint(20) unsigned not null,
|
||||
supplier_id int default null,
|
||||
tracking_number varchar(256) default null,
|
||||
arrival_date DATETIME DEFAULT null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
create table stock_batch (
|
||||
id int not null primary key auto_increment,
|
||||
supplier_id int unsigned not null,
|
||||
user_id int not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
);
|
||||
|
||||
|
||||
|
||||
create table stock_entries
|
||||
(
|
||||
id int not null auto_increment primary key,
|
||||
physical_item_id int not null,
|
||||
supplier_id int unsigned not null,
|
||||
count int default 0 not null,
|
||||
count int default 0 not null, #needitovatelny ve formulari / jen odpis, inventura
|
||||
original_count int default 0 not null,
|
||||
price double null,
|
||||
bought date default null,
|
||||
description text null,
|
||||
note text null,
|
||||
stock_position_id int not null,
|
||||
country_of_origin_id int unsigned not null,
|
||||
on_the_way bool not null default false,
|
||||
stock_batch_id int default null,
|
||||
created_at timestamp default current_timestamp() not null,
|
||||
created_by tinyint unsigned default 1 not null,
|
||||
updated_at timestamp default current_timestamp() not null on update current_timestamp(),
|
||||
updated_by tinyint unsigned null
|
||||
|
||||
updated_by tinyint unsigned null,
|
||||
FOREIGN KEY (stock_batch_id) REFERENCES stock_batch (id)
|
||||
);
|
||||
|
||||
|
||||
create table stock_attributes (
|
||||
# barvy, apod, original bez loga, v krabicce s potiskem, repliky v krabici bez potisku
|
||||
|
||||
create table stock_attributes
|
||||
(
|
||||
id int primary key auto_increment,
|
||||
name varchar(64) not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
);
|
||||
|
||||
create table stock_attributes_translation (
|
||||
create table stock_attributes_translation
|
||||
(
|
||||
stock_attributes_id int not null,
|
||||
language_id int not null,
|
||||
translated_name varchar(128) not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
FOREIGN KEY (stock_attributes_id) REFERENCES stock_attributes (id),
|
||||
PRIMARY KEY (stock_attributes_id, language_id)
|
||||
);
|
||||
|
||||
create table stock_attribute_values (
|
||||
create table stock_attribute_values
|
||||
(
|
||||
id int primary key auto_increment,
|
||||
stock_attribute_id int not null,
|
||||
name varchar(64) not null,
|
||||
language_id int not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
FOREIGN KEY (stock_attribute_id) REFERENCES stock_attributes (id)
|
||||
);
|
||||
|
||||
create table stock_entry2attributes (
|
||||
create table stock_entry2attributes
|
||||
(
|
||||
stock_attributes_id int not null,
|
||||
stock_attribute_value_id int not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
FOREIGN KEY (stock_attributes_id) REFERENCES stock_attributes (id),
|
||||
FOREIGN KEY (stock_attribute_value_id) REFERENCES stock_attribute_values (id),
|
||||
PRIMARY KEY (stock_attributes_id, stock_attribute_value_id)
|
||||
);
|
||||
|
||||
|
||||
create table stock_batch_files
|
||||
(
|
||||
id int primary key auto_increment,
|
||||
filename text not null,
|
||||
file_data longblob not null, # object storage
|
||||
file_type enum ('invoice', 'label', 'other'),
|
||||
stock_batch_id int not null,
|
||||
share_location text default null, # direct nahrat sem
|
||||
user_id bigint(20) unsigned not null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
FOREIGN KEY (stock_batch_id) REFERENCES stock_batch (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
|
||||
create table stock_entries_status
|
||||
(
|
||||
id int primary key auto_increment,
|
||||
name varchar(64) not null, #naskladneno, spocitano, presunuto na XY, poskozeno, chybi, nadbyva
|
||||
description varchar(256) default null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now()
|
||||
);
|
||||
|
||||
|
||||
|
||||
create table stock_entries_status_history
|
||||
(
|
||||
id int primary key auto_increment,
|
||||
stock_entries_id int not null,
|
||||
section_id int default null,
|
||||
stock_entries_status_id int not null,
|
||||
status_note text default null,
|
||||
created_at DATETIME DEFAULT now(),
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE now(),
|
||||
FOREIGN KEY (stock_entries_id) REFERENCES stock_entries (id),
|
||||
FOREIGN KEY (section_id) REFERENCES stock_section (section_id),
|
||||
FOREIGN KEY (stock_entries_status_id) REFERENCES stock_entries_status (id)
|
||||
);
|
||||
|
||||
|
@ -4,6 +4,45 @@
|
||||
@plugin '@tailwindcss/typography';
|
||||
@plugin "daisyui";
|
||||
|
||||
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "insanedestroyer";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(20% 0.042 265.755);
|
||||
--color-base-200: oklch(21% 0.034 264.665);
|
||||
--color-base-300: oklch(27% 0.033 256.848);
|
||||
--color-base-content: oklch(96% 0.003 264.542);
|
||||
--color-primary: oklch(44% 0.043 257.281);
|
||||
--color-primary-content: oklch(98% 0.003 247.858);
|
||||
--color-secondary: oklch(64% 0.2 131.684);
|
||||
--color-secondary-content: oklch(98% 0.031 120.757);
|
||||
--color-accent: oklch(59% 0.145 163.225);
|
||||
--color-accent-content: oklch(97% 0.021 166.113);
|
||||
--color-neutral: oklch(37% 0.034 259.733);
|
||||
--color-neutral-content: oklch(98% 0.002 247.839);
|
||||
--color-info: oklch(62% 0.214 259.815);
|
||||
--color-info-content: oklch(97% 0.014 254.604);
|
||||
--color-success: oklch(72% 0.219 149.579);
|
||||
--color-success-content: oklch(98% 0.018 155.826);
|
||||
--color-warning: oklch(70% 0.213 47.604);
|
||||
--color-warning-content: oklch(98% 0.016 73.684);
|
||||
--color-error: oklch(63% 0.237 25.331);
|
||||
--color-error-content: oklch(97% 0.013 17.38);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 2rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../vendor/laravel/jetstream/**/*.blade.php';
|
||||
|
||||
|
28
resources/js/Components/ModalPDA.tsx
Normal file
28
resources/js/Components/ModalPDA.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
// components/Modal.tsx
|
||||
import React from 'react'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ModalPDA: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box relative">
|
||||
<button
|
||||
className="btn btn-sm btn-circle absolute right-2 top-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalPDA
|
22
resources/js/Components/Tile.tsx
Normal file
22
resources/js/Components/Tile.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
// Tile.tsx
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
interface TileProps {
|
||||
title: string
|
||||
icon: IconProp
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const Tile: React.FC<TileProps> = ({ title, icon, onClick }) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="card card-bordered hover:shadow-lg cursor-pointer flex flex-col items-center p-4 bg-[#9228b9]"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} size="2x" />
|
||||
<span className="mt-2 font-medium">{title}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default Tile
|
13
resources/js/Components/TileLayout.tsx
Normal file
13
resources/js/Components/TileLayout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
interface TileLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const TileLayout: React.FC<TileLayoutProps> = ({ children }) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 p-4">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default TileLayout
|
49
resources/js/Components/modals/CountStockModal.tsx
Normal file
49
resources/js/Components/modals/CountStockModal.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
// components/modals/CountStockModal.tsx
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const CountStockModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [quantity, setQuantity] = React.useState(0)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('/api/set-stock', { quantity }, { withCredentials: true })
|
||||
toast.success('Stock set!')
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error('Failed to set stock')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h3 className="font-bold text-lg">Set Stock</h3>
|
||||
<label className="label">
|
||||
<span className="label-text">Quantity</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={e => setQuantity(+e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default CountStockModal
|
49
resources/js/Components/modals/OtherReplacementModal.tsx
Normal file
49
resources/js/Components/modals/OtherReplacementModal.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
// components/modals/OtherReplacementModal.tsx
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const OtherReplacementModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [quantity, setQuantity] = React.useState(0)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('/api/set-stock', { quantity }, { withCredentials: true })
|
||||
toast.success('Stock set!')
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error('Failed to set stock')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h3 className="font-bold text-lg">Set Stock</h3>
|
||||
<label className="label">
|
||||
<span className="label-text">Quantity</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={e => setQuantity(+e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default OtherReplacementModal
|
49
resources/js/Components/modals/SetStockModal.tsx
Normal file
49
resources/js/Components/modals/SetStockModal.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
// components/modals/SetStockModal.tsx
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SetStockModal: React.FC<Props> = ({ onClose }) => {
|
||||
const [quantity, setQuantity] = React.useState(0)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('/api/set-stock', { quantity }, { withCredentials: true })
|
||||
toast.success('Stock set!')
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error('Failed to set stock')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h3 className="font-bold text-lg">Set Stock</h3>
|
||||
<label className="label">
|
||||
<span className="label-text">Quantity</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={e => setQuantity(+e.target.value)}
|
||||
className="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SetStockModal
|
243
resources/js/Pages/BatchCounting.tsx
Normal file
243
resources/js/Pages/BatchCounting.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
// resources/js/Pages/BatchCounting.tsx
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import AppLayout from '@/Layouts/AppLayout'
|
||||
import axios from 'axios'
|
||||
import { toast, Toaster } from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
MaterialReactTable,
|
||||
type MRT_ColumnDef,
|
||||
} from 'material-react-table'
|
||||
|
||||
import { StockEntry, StockBatch } from '@/types'
|
||||
|
||||
const statusMapping = {
|
||||
CORRECT: 2, // NEW_GOODS_COUNTED
|
||||
MISSING_ITEMS: 7, // NEW_GOODS_MISSING
|
||||
BROKEN_ITEMS: 6, // NEW_GOODS_DAMAGED
|
||||
}
|
||||
|
||||
export default function BatchCounting() {
|
||||
const { selectedBatch } = usePage<{ selectedBatch: StockBatch }>().props
|
||||
|
||||
const [entries, setEntries] = useState<StockEntry[]>(selectedBatch.stock_entries)
|
||||
|
||||
// pin items without any of (2,6,7) to top
|
||||
const data = useMemo(() => {
|
||||
const noStatus: StockEntry[] = []
|
||||
const withStatus: StockEntry[] = []
|
||||
selectedBatch.stock_entries.forEach((e) => {
|
||||
const has = e.status_history.some(h =>
|
||||
[2, 6, 7].includes(h.stock_entries_status_id)
|
||||
)
|
||||
if (has) withStatus.push(e)
|
||||
else noStatus.push(e)
|
||||
})
|
||||
return [...noStatus, ...withStatus]
|
||||
}, [selectedBatch.stock_entries])
|
||||
|
||||
const getLatestRelevant = (history: any[]) => {
|
||||
const relevant = history
|
||||
.filter(h => [2, 6, 7].includes(h.stock_entries_status_id))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
return relevant.length ? relevant[relevant.length - 1] : null;
|
||||
};
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(() => [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
size: 60,
|
||||
Cell: ({ row }) => {
|
||||
const latest = getLatestRelevant(row.original.status_history);
|
||||
if (!latest) return null;
|
||||
switch (latest.stock_entries_status_id) {
|
||||
case 2:
|
||||
return <span className="text-green-600">✓</span>;
|
||||
case 6:
|
||||
return <span className="text-orange-600">⚠️</span>;
|
||||
case 7:
|
||||
return <span className="text-red-600">✖️</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{ accessorFn: e => e.id, header: 'ID' },
|
||||
{ accessorFn: e => e.physical_item.name, header: 'Item' },
|
||||
{ accessorFn: e => e.supplier.name, header: 'Supplier' },
|
||||
{ accessorFn: e => e.quantity, header: 'Qty' },
|
||||
], [])
|
||||
|
||||
// modal state
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [selectedEntry, setSelectedEntry] = useState<StockEntry | null>(null)
|
||||
const [selectedStatus, setSelectedStatus] = useState<keyof typeof statusMapping | null>(null)
|
||||
const [count, setCount] = useState<number>(0)
|
||||
|
||||
const openModal = (entry: StockEntry) => {
|
||||
setSelectedEntry(entry)
|
||||
setSelectedStatus(null)
|
||||
setCount(0)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const submitStatus = async () => {
|
||||
if (!selectedEntry || !selectedStatus) return
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/stockActions/${selectedEntry.id}/status`, {
|
||||
status: statusMapping[selectedStatus],
|
||||
count: selectedStatus === 'CORRECT' ? undefined : count,
|
||||
})
|
||||
|
||||
const newHist: typeof selectedEntry.status_history[0] = response.data.history
|
||||
|
||||
// 2. Append the new history into our entries state
|
||||
setEntries(prev =>
|
||||
prev.map((e) =>
|
||||
e.id === selectedEntry.id
|
||||
? { ...e, status_history: [...e.status_history, newHist] }
|
||||
: e
|
||||
)
|
||||
)
|
||||
|
||||
toast.success('Status updated successfully!')
|
||||
setModalOpen(false)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update status:', error)
|
||||
toast.error(
|
||||
error.response?.data?.message ||
|
||||
'Failed to update status. Please try again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
title="Stock Entries"
|
||||
renderHeader={() => (
|
||||
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
Stock Entries
|
||||
</h2>
|
||||
)}
|
||||
>
|
||||
<Head title="Batch counting" />
|
||||
<Toaster position="top-center" />
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
Batch: {selectedBatch.name}
|
||||
</h3>
|
||||
|
||||
<MaterialReactTable
|
||||
columns={columns}
|
||||
data={[
|
||||
...entries.filter(e => !e.status_history.some(h => [2,6,7].includes(h.stock_entries_status_id))),
|
||||
...entries.filter(e => e.status_history.some(h => [2,6,7].includes(h.stock_entries_status_id))),
|
||||
]}
|
||||
enableRowSelection={false}
|
||||
muiTableBodyRowProps={({ row }) => {
|
||||
const latest = getLatestRelevant(row.original.status_history);
|
||||
let bgColor: string | undefined;
|
||||
if (latest) {
|
||||
switch (latest.stock_entries_status_id) {
|
||||
case 2:
|
||||
bgColor = 'rgba(220, 253, 213, 0.5)'; // green-50
|
||||
break;
|
||||
case 6:
|
||||
bgColor = 'rgba(255, 247, 237, 0.5)'; // orange-50
|
||||
break;
|
||||
case 7:
|
||||
bgColor = 'rgba(254, 226, 226, 0.5)'; // red-50
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
onClick: () => openModal(row.original),
|
||||
sx: {
|
||||
cursor: 'pointer',
|
||||
backgroundColor: bgColor,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
{modalOpen && selectedEntry && (
|
||||
<div className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">
|
||||
Update "{selectedEntry.physical_item.name}"
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 my-4">
|
||||
<button
|
||||
className={`btn btn-success ${
|
||||
selectedStatus === 'CORRECT' ? '' : 'btn-outline'
|
||||
}`}
|
||||
onClick={() => { setSelectedStatus('CORRECT'); setCount(0) }}
|
||||
>
|
||||
CORRECT
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-error ${
|
||||
selectedStatus === 'MISSING_ITEMS' ? '' : 'btn-outline'
|
||||
}`}
|
||||
onClick={() => setSelectedStatus('MISSING_ITEMS')}
|
||||
>
|
||||
MISSING ITEMS
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-warning ${
|
||||
selectedStatus === 'BROKEN_ITEMS' ? '' : 'btn-outline'
|
||||
}`}
|
||||
onClick={() => setSelectedStatus('BROKEN_ITEMS')}
|
||||
>
|
||||
BROKEN ITEMS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedStatus && selectedStatus !== 'CORRECT' && (
|
||||
<div className="form-control mb-4">
|
||||
<label className="label">
|
||||
<span className="label-text">Count</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-bordered"
|
||||
value={count}
|
||||
min={0}
|
||||
onChange={e => setCount(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-action">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
!selectedStatus ||
|
||||
(selectedStatus !== 'CORRECT' && count <= 0)
|
||||
}
|
||||
onClick={submitStatus}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
185
resources/js/Pages/PdaView.tsx
Normal file
185
resources/js/Pages/PdaView.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import { Head, usePage } from '@inertiajs/react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
faArrowLeft,
|
||||
faQuestionCircle,
|
||||
faBoxOpen,
|
||||
faClipboardList,
|
||||
faCubes,
|
||||
faPlus,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { toast, Toaster } from 'react-hot-toast'
|
||||
|
||||
import Tile from '../Components/Tile'
|
||||
import TileLayout from '../Components/TileLayout'
|
||||
import ModalPDA from '../Components/ModalPDA'
|
||||
|
||||
// your extracted modal forms:
|
||||
import SetStockModal from '../Components/modals/SetStockModal'
|
||||
import OtherReplacementModal from '../Components/modals/OtherReplacementModal'
|
||||
import CountStockModal from '../Components/modals/CountStockModal'
|
||||
|
||||
type Role = 'Expedice' | 'Skladnik'
|
||||
|
||||
// actions per role
|
||||
const roleActions: Record<Role, string[]> = {
|
||||
Expedice: ['stockSectionScanned', 'labelScanned'],
|
||||
Skladnik: ['batchScan', 'stockScan'],
|
||||
}
|
||||
|
||||
// configuration for each tile: either opens a modal (modalKey),
|
||||
// or performs an API call (onClick)
|
||||
type TileConfig = {
|
||||
title: string
|
||||
icon: any
|
||||
modalKey?: ModalKey
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const tilesConfig: Record<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: 'Present but Shouldn’t',
|
||||
icon: faClipboardList,
|
||||
onClick: async () => {
|
||||
// example direct axios call
|
||||
try {
|
||||
await axios.post('/api/presence-error', {}, { withCredentials: true })
|
||||
toast.success('Reported!')
|
||||
} catch {
|
||||
toast.error('Failed to report')
|
||||
}
|
||||
},
|
||||
},
|
||||
{ title: 'Other Replacement (na test/one time)', icon: faPlus, modalKey: 'otherReplacement' },
|
||||
{ title: 'Pridej vazbu na jiny ovladac', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Pridej docasnou vazbu (nez prijde jine zbozi, i casove omezene, nejnizsi priorita)', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Doplnit zbozi / dej vic kusu', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Report - chci zmenit pozici(bliz / dal od expedice)', icon: faPlus, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Pultovy prodej', icon: faPlus, onClick: () => toast('- naskenuju na webu / naskenuju na skladovem miste, obj. se udela automaticky, zakaznik si muze v rohu na PC vyplnit osobni udaje na special formulari - pak customera prida obsluha do obj') },
|
||||
],
|
||||
labelScanned: [
|
||||
{ title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
]
|
||||
},
|
||||
Skladnik: {
|
||||
batchScan: [
|
||||
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Set Stock', icon: faCubes, modalKey: 'setStock' },
|
||||
{ title: 'Count Stock', icon: faPlus, modalKey: 'countStock' },
|
||||
{ title: 'Stitkovani (male stitky)', icon: faClipboardList, onClick: () => toast('Stitkovani (male stitky)') },
|
||||
{ title: 'Batch Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Tisk QR kod na krabice', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
],
|
||||
stockScan: [
|
||||
{ title: 'Info', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Zmena skladoveho mista (i presun jen casti kusu)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Uprava skladoveho mista (mene sekci, apod)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Zmena poctu', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
{ title: 'Discard (odebrat ze skladoveho mista / posilame zpet)', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
],
|
||||
others: [
|
||||
{ title: 'Nove skladove misto', icon: faClipboardList, onClick: () => toast('Batch Info clicked') },
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
type ModalKey = 'setStock' | 'otherReplacement' | 'countStock' | null
|
||||
|
||||
export default function PdaView() {
|
||||
const {
|
||||
auth: { user },
|
||||
} = usePage<{ auth: { user: { role: string } } }>().props
|
||||
|
||||
const [role, setRole] = React.useState<Role>(
|
||||
user.role === 'admin' ? 'Expedice' : (user.role as Role)
|
||||
)
|
||||
const [action, setAction] = React.useState<string>('')
|
||||
const [activeModal, setActiveModal] = React.useState<ModalKey>(null)
|
||||
|
||||
const isAdmin = true
|
||||
// const isAdmin = user.role === 'admin'
|
||||
const tabs: Role[] = ['Expedice', 'Skladnik']
|
||||
|
||||
const closeModal = () => setActiveModal(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="PDA View" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Admin tabs */}
|
||||
{isAdmin && (
|
||||
<div className="tabs justify-center bg-base-100">
|
||||
{tabs.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`tab ${role === r ? 'tab-active' : ''}`}
|
||||
onClick={() => {
|
||||
setRole(r)
|
||||
setAction('')
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action selectors */}
|
||||
<div className="flex justify-center space-x-4 space-y-4 sm:space-y-0 p-4 sm:flex-nowrap flex-wrap">
|
||||
{[...(roleActions[role] || []), 'clear'].map((act) => (
|
||||
<button
|
||||
key={act}
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
const newAct = act === 'clear' ? '' : act
|
||||
setAction(newAct)
|
||||
toast(`Action set to ${newAct || 'none'}`)
|
||||
}}
|
||||
>
|
||||
{act === 'clear' ? 'Clear Action' : act}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tiles */}
|
||||
<TileLayout>
|
||||
{action &&
|
||||
(tilesConfig[role][action] || []).map(({ title, icon, onClick, modalKey }) => (
|
||||
<Tile
|
||||
key={title}
|
||||
title={title}
|
||||
icon={icon}
|
||||
onClick={() => {
|
||||
if (modalKey) setActiveModal(modalKey)
|
||||
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} />}
|
||||
</ModalPDA>
|
||||
|
||||
<Toaster position="top-right" />
|
||||
</>
|
||||
)
|
||||
}
|
711
resources/js/Pages/StockBatch.tsx
Normal file
711
resources/js/Pages/StockBatch.tsx
Normal file
@ -0,0 +1,711 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AppLayout from '@/Layouts/AppLayout';
|
||||
import axios from 'axios';
|
||||
import { toast, Toaster } from 'react-hot-toast';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
type MRT_ColumnDef,
|
||||
type MRT_PaginationState,
|
||||
type MRT_SortingState,
|
||||
} from 'material-react-table';
|
||||
import { Combobox } from '@headlessui/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {faCheck, faQrcode, faListOl} from '@fortawesome/free-solid-svg-icons';
|
||||
import {StockBatch, StockSection, Supplier} from '@/types';
|
||||
import {size} from "lodash";
|
||||
import { router } from "@inertiajs/react";
|
||||
interface DropdownOption { id: number; name: string }
|
||||
interface FileWithType { file: File; fileType: 'invoice' | 'label' | 'other' }
|
||||
|
||||
interface StockEntry {
|
||||
id: number;
|
||||
physical_item_id: number | null;
|
||||
supplier_id: number | null;
|
||||
count: number;
|
||||
price: number | null;
|
||||
bought: string | null;
|
||||
description: string | null;
|
||||
note: string | null;
|
||||
stock_position_id: number | null;
|
||||
country_of_origin_id: number | null;
|
||||
on_the_way: boolean;
|
||||
stock_batch_id: number;
|
||||
physical_item?: DropdownOption;
|
||||
supplier?: DropdownOption;
|
||||
stock_position?: { id: number; line: string; rack: string; shelf: string; position: string };
|
||||
sections?: (StockSection & { pivot: { section_id:number; count: number; created_at: string; updated_at: string | null } })[];
|
||||
}
|
||||
|
||||
const defaultBatchForm = { supplierId: null as number | null, tracking_number: '', arrival_date: '' };
|
||||
const defaultEntryForm: Omit<StockEntry, 'id'> = {
|
||||
physical_item_id: null,
|
||||
supplier_id: null,
|
||||
count: 0,
|
||||
price: null,
|
||||
bought: null,
|
||||
description: null,
|
||||
note: null,
|
||||
country_of_origin_id: null,
|
||||
on_the_way: false,
|
||||
surplus_item: false,
|
||||
stock_batch_id: null,
|
||||
};
|
||||
|
||||
export default function StockBatches() {
|
||||
const [batches, setBatches] = useState<StockBatch[]>([]);
|
||||
const [statuses, setStatuses] = useState<[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchCount, setBatchCount] = useState(0);
|
||||
const [batchPagination, setBatchPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||||
const [batchSorting, setBatchSorting] = useState<MRT_SortingState>([{ id: 'updatedAt', desc: true }]);
|
||||
const [batchFilter, setBatchFilter] = useState('');
|
||||
|
||||
const createDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [batchForm, setBatchForm] = useState(defaultBatchForm);
|
||||
const [batchFiles, setBatchFiles] = useState<FileWithType[]>([]);
|
||||
|
||||
const viewDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [selectedBatch, setSelectedBatch] = useState<StockBatch | null>(null);
|
||||
const [entries, setEntries] = useState<StockEntry[]>([]);
|
||||
const [entriesLoading, setEntriesLoading] = useState(false);
|
||||
const [entriesCount, setEntriesCount] = useState(0);
|
||||
const [entriesPagination, setEntriesPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||||
const [entriesSorting, setEntriesSorting] = useState<MRT_SortingState>([{ id: 'id', desc: false }]);
|
||||
const [entriesFilter, setEntriesFilter] = useState('');
|
||||
|
||||
const [onTheWayEntries, setOnTheWayEntries] = useState<StockEntry[]>([]);
|
||||
const [entriesOnTheWayLoading, setEntriesOnTheWayLoading] = useState(false);
|
||||
const [onTheWayEntriesCount, setOnTheWayEntriesCount] = useState(0);
|
||||
const [onTheWayEntriesPagination, setOnTheWayEntriesPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||||
const [onTheWayEntriesSorting, setOnTheWayEntriesSorting] = useState<MRT_SortingState>([{ id: 'id', desc: false }]);
|
||||
const [onTheWayEntriesFilter, setOnTheWayEntriesFilter] = useState('');
|
||||
|
||||
const [onTheWayEntriesSelections, setOnTheWayEntriesSelections] = useState<number[]>([]);
|
||||
|
||||
const entryDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
|
||||
const [entryForm, setEntryForm] = useState<Omit<StockEntry, 'id'>>({ ...defaultEntryForm });
|
||||
|
||||
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
|
||||
const [positions, setPositions] = useState<DropdownOption[]>([]);
|
||||
const [countries, setCountries] = useState<DropdownOption[]>([]);
|
||||
|
||||
const [itemQuery, setItemQuery] = useState('');
|
||||
const filteredItems = useMemo(
|
||||
() => itemQuery === '' ? physicalItems : physicalItems.filter(i => i.name.toLowerCase().includes(itemQuery.toLowerCase())),
|
||||
[itemQuery, physicalItems]
|
||||
);
|
||||
|
||||
// Add this state alongside your other hooks:
|
||||
const [positionRows, setPositionRows] = useState<{
|
||||
stock_position_id: number | null;
|
||||
count: number | null;
|
||||
}[]>([{ stock_position_id: null, count: null }]);
|
||||
|
||||
// Handler to update a row
|
||||
const handlePositionRowChange = (index: number, field: 'stock_position_id' | 'count', value: number | null) => {
|
||||
setPositionRows(prev => {
|
||||
const rows = prev.map((r, i) => i === index ? { ...r, [field]: value } : r);
|
||||
// if editing the last row's count, and it's a valid number, append a new empty row
|
||||
if (field === 'count' && index === prev.length - 1 && value && value > 0) {
|
||||
rows.push({ stock_position_id: null, count: null });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (value: string, withTime = true) => {
|
||||
const d = new Date(value);
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
if (!withTime) return `${dd}-${mm}-${yyyy}`;
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${dd}-${mm}-${yyyy} ${hh}:${mi}`;
|
||||
};
|
||||
|
||||
const batchColumns = useMemo<MRT_ColumnDef<StockBatch>[]>(() => [
|
||||
{ accessorKey: 'id', header: 'ID' },
|
||||
{ accessorKey: 'supplier.name', header: 'Supplier' },
|
||||
{ accessorKey: 'tracking_number', header: 'Tracking #' },
|
||||
{ accessorKey: 'arrival_date', header: 'Arrival Date', Cell: ({ cell }) => formatDate(cell.getValue<string>(), false) },
|
||||
{ accessorFn: r => r.files?.length ?? 0, id: 'files', header: 'Files' },
|
||||
{ accessorFn: r => r.stock_entries?.length ?? 0, id: 'items', header: 'Items' },
|
||||
{ accessorKey: 'created_at', header: 'Created', Cell: ({ cell }) => formatDate(cell.getValue<string>()) },
|
||||
{ accessorKey: 'updated_at', header: 'Updated', Cell: ({ cell }) => formatDate(cell.getValue<string>()) },
|
||||
], []);
|
||||
|
||||
const entryColumns = 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: 'physical_item.manufacturer.name', header: 'Brand', size: 150 },
|
||||
{ accessorKey: 'count', header: 'Count', size: 100 },
|
||||
{ accessorKey: 'price', header: 'Price', size: 100, Cell: ({ cell }) => cell.getValue<number>()?.toFixed(2) || '-' },
|
||||
{ accessorKey: 'bought', header: 'Bought Date', size: 120 },
|
||||
{ accessorKey: 'on_the_way', header: 'On The Way', size: 100, Cell: ({ cell }) => cell.getValue<boolean>() ? 'Yes' : 'No' },
|
||||
{ accessorKey: 'stock_position', header: 'Position', size: 150, Cell: ({ row }) => {
|
||||
const pos = row.original.stock_position;
|
||||
return pos ? `${pos.line}-${pos.rack}-${pos.shelf}-${pos.position}` : '-';
|
||||
}
|
||||
},
|
||||
{ accessorKey: 'updated_at', header: 'Last Updated', size: 150 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const entryOnTheWayColumns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'select',
|
||||
header: 'Select',
|
||||
Cell: ({ row }) => {
|
||||
const id = row.original.id;
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onTheWayEntriesSelections.includes(id)}
|
||||
onChange={() => setOnTheWayEntriesSelections(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ accessorKey: 'id', header: 'ID' },
|
||||
{ accessorKey: 'physical_item.name', header: 'Item' },
|
||||
{ accessorKey: 'supplier.name', header: 'Supplier' },
|
||||
], [onTheWayEntriesSelections]
|
||||
);
|
||||
|
||||
useEffect(() => { fetchOptions(); fetchEntriesOnTheWay(); fetchStatusList();}, []);
|
||||
useEffect(() => { fetchBatches(); }, [batchPagination, batchSorting, batchFilter]);
|
||||
useEffect(() => { if (selectedBatch) fetchEntries(selectedBatch.id); }, [entriesPagination, entriesSorting, entriesFilter]);
|
||||
|
||||
async function fetchSuppliers() {
|
||||
try { const res = await axios.get('/api/stockBatches/options'); setSuppliers(res.data.suppliers); }
|
||||
catch { toast.error('Cannot load suppliers'); }
|
||||
}
|
||||
|
||||
async function fetchOptions() {
|
||||
try {
|
||||
const res = await axios.get('/api/stockData/options');
|
||||
setSuppliers(res.data.suppliers);
|
||||
setPositions(res.data.stockPositions);
|
||||
console.log(res.data.stockPositions);
|
||||
setCountries(res.data.countriesOrigin);
|
||||
} catch { toast.error('Cannot load entry options'); }
|
||||
}
|
||||
|
||||
async function fetchBatches() {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/stockBatches', { params: { page: batchPagination.pageIndex+1, perPage: batchPagination.pageSize, sortField: batchSorting[0].id, sortOrder: batchSorting[0].desc?'desc':'asc', filter: batchFilter } });
|
||||
console.log(res.data.data);setBatches(res.data.data); setBatchCount(res.data.meta.total);
|
||||
} catch { toast.error('Cannot fetch batches'); }
|
||||
finally { setBatchLoading(false); }
|
||||
}
|
||||
|
||||
async function fetchEntries(batchId: number) {
|
||||
setEntriesLoading(true);
|
||||
console.log("fetching entries for batch ", batchId);
|
||||
try {
|
||||
const res = await axios.get(`/api/stockBatches/${batchId}/entries`, { params: { page: entriesPagination.pageIndex+1, perPage: entriesPagination.pageSize, sortField: entriesSorting[0].id, sortOrder: entriesSorting[0].desc?'desc':'asc', filter: entriesFilter } });
|
||||
console.log(res.data.data);setEntries(res.data.data); setEntriesCount(size(res.data.data));
|
||||
} catch (error) { toast.error('Cannot fetch entries'); console.error(error); }
|
||||
finally { setEntriesLoading(false); }
|
||||
}
|
||||
|
||||
async function fetchEntriesOnTheWay() {
|
||||
setEntriesOnTheWayLoading(true);
|
||||
console.log("fetching entries on the way ");
|
||||
try {
|
||||
const res = await axios.get(`/api/stockDataOnTheWay`);
|
||||
console.log(res.data.data); setOnTheWayEntries(res.data.data); setOnTheWayEntriesCount(size(res.data.data));
|
||||
} catch (error) { toast.error('Cannot fetch entries'); console.error(error); }
|
||||
finally { setEntriesOnTheWayLoading(false); }
|
||||
}
|
||||
|
||||
const openCreate = () => createDialogRef.current?.showModal();
|
||||
const closeCreate = () => { createDialogRef.current?.close(); setBatchForm(defaultBatchForm); setBatchFiles([]); };
|
||||
|
||||
const openView = (batch: StockBatch) => { setSelectedBatch(batch); fetchEntries(batch.id); viewDialogRef.current?.showModal(); };
|
||||
const closeView = () => { viewDialogRef.current?.close(); setEntries([]); }
|
||||
|
||||
const openEntry = (entry?: StockEntry) => {
|
||||
fetchEntriesOnTheWay();
|
||||
|
||||
if (entry) {
|
||||
setEditingEntry(entry);
|
||||
setEntryForm({ ...entry });
|
||||
|
||||
// Build rows from whatever pivot’d sections came back
|
||||
const rows = entry.sections?.map(sec => ({
|
||||
stock_position_id: sec.pivot.section_id,
|
||||
count: sec.pivot.count,
|
||||
})) || [];
|
||||
console.log(entry.sections);
|
||||
|
||||
// Append an empty row so the user can add more
|
||||
setPositionRows([...rows, { stock_position_id: null, count: null }]);
|
||||
|
||||
} else {
|
||||
setEditingEntry(null);
|
||||
setEntryForm({ ...defaultEntryForm, stock_batch_id: selectedBatch!.id });
|
||||
// brand-new: start with one empty row
|
||||
setPositionRows([{ stock_position_id: null, count: null }]);
|
||||
}
|
||||
|
||||
entryDialogRef.current?.showModal();
|
||||
};
|
||||
const closeEntry = () => { entryDialogRef.current?.close(); setEditingEntry(null); };
|
||||
|
||||
const handleBatchInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setBatchForm(prev => ({ ...prev, [name]: name === 'supplierId' ? (value ? parseInt(value) : null) : value }));
|
||||
};
|
||||
|
||||
const handleBatchFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setBatchFiles(Array.from(e.target.files).map(file => ({ file, fileType: 'other' })));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchFileTypeChange = (idx: number, type: FileWithType['fileType']) => {
|
||||
setBatchFiles(prev => prev.map((f, i) => i === idx ? { ...f, fileType: type } : f));
|
||||
};
|
||||
|
||||
const removeBatchFile = (idx: number) => {
|
||||
setBatchFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleBatchSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (batchForm.supplierId) fd.append('supplier_id', batchForm.supplierId.toString());
|
||||
if (batchForm.tracking_number) fd.append('tracking_number', batchForm.tracking_number);
|
||||
if (batchForm.arrival_date) fd.append('arrival_date', batchForm.arrival_date);
|
||||
batchFiles.forEach(({ file, fileType }, i) => { fd.append(`files[${i}]`, file); fd.append(`file_types[${i}]`, fileType); });
|
||||
await axios.post('/api/stockBatches', fd, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
toast.success('Batch created'); closeCreate(); fetchBatches();
|
||||
} catch (err: any) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 422) {
|
||||
Object.values(err.response.data.errors).flat().forEach((m: string) => toast.error(m));
|
||||
} else {
|
||||
toast.error('Failed to create batch');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchAddEntries = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log(selectedBatch);
|
||||
try {
|
||||
await axios.put(`/api/stockBatches/${selectedBatch.id}/entries`, { ids: onTheWayEntriesSelections });
|
||||
toast.success('Batch entries updated successfully');
|
||||
closeEntry();
|
||||
fetchBatches();
|
||||
setSelectedBatch(null);
|
||||
setOnTheWayEntriesSelections([]);
|
||||
closeView();
|
||||
} catch {
|
||||
toast.error('Batch entries update failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setEntryForm(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : type === 'number' ? parseFloat(value) || null : value || null,
|
||||
} as any));
|
||||
};
|
||||
|
||||
const handleEntrySubmit = async (e: React.FormEvent) => {
|
||||
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingEntry) await axios.put(`/api/stockData/${editingEntry.id}`, { entryForm, sections: positionRows.filter(r => r.stock_position_id && r.count)});
|
||||
else await axios.post(`/api/stockData`, entryForm);
|
||||
toast.success(editingEntry ? 'Entry updated' : 'Entry created');
|
||||
fetchEntries(selectedBatch!.id); closeEntry();
|
||||
} catch { toast.error('Cannot save entry'); }
|
||||
};
|
||||
|
||||
// Before your return JSX, replace the old flag with this:
|
||||
const hasNonCountedStatus = selectedBatch?.stock_entries?.some(stockEntry => {
|
||||
// 1. Only statuses with no section:
|
||||
const nullSectionStatuses = stockEntry.status_history?.filter(h => h.section_id === null) ?? [];
|
||||
if (nullSectionStatuses.length === 0) return true;
|
||||
// 2. Find the *latest* one by timestamp:
|
||||
const latest = nullSectionStatuses.reduce((prev, curr) =>
|
||||
new Date(prev.created_at) > new Date(curr.created_at) ? prev : curr
|
||||
);
|
||||
// 3. Check if that status isn’t “COUNTED” (id === 2)
|
||||
return latest.stock_entries_status_id !== 2;
|
||||
});
|
||||
|
||||
function calculateStatusRatio(
|
||||
entries: StockEntry[],
|
||||
statusId: number
|
||||
): { count: number; total: number; ratio: number } {
|
||||
const total = entries.length;
|
||||
const count = entries.filter((entry) =>
|
||||
entry.status_history?.some((h) => h.stock_entries_status_id === statusId)
|
||||
).length;
|
||||
const ratio = total > 0 ? count / total : 0;
|
||||
return { count, total, ratio };
|
||||
}
|
||||
|
||||
|
||||
async function fetchStatusList() {
|
||||
try {
|
||||
const res = await axios.get('/api/stockStatusList');
|
||||
setStatuses(res.data.statuses);
|
||||
} catch {
|
||||
toast.error('Failed to load items');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPhysicalItems(query: string) {
|
||||
try {
|
||||
const res = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
|
||||
setPhysicalItems(res.data.physicalItems);
|
||||
} catch {
|
||||
toast.error('Failed to load items');
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500);
|
||||
return () => clearTimeout(delay);
|
||||
}, [itemQuery]);
|
||||
|
||||
const statusIds = [2, 6, 7, 8]
|
||||
|
||||
return (
|
||||
<AppLayout title="Stock Batches" renderHeader={() => <h2 className="font-semibold text-xl">Stock Batches</h2>}>
|
||||
<Head title="Stock Batches" />
|
||||
<Toaster position="top-center" />
|
||||
|
||||
<div className="py-12">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-xl sm:rounded-lg p-6">
|
||||
<div className="flex justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">Batches</h1>
|
||||
<button onClick={openCreate} className="btn btn-primary">Add New Batch</button>
|
||||
</div>
|
||||
<MaterialReactTable
|
||||
columns={batchColumns}
|
||||
data={batches}
|
||||
manualPagination manualSorting enableGlobalFilter
|
||||
onPaginationChange={setBatchPagination}
|
||||
onSortingChange={setBatchSorting}
|
||||
onGlobalFilterChange={setBatchFilter}
|
||||
rowCount={batchCount}
|
||||
state={{ isLoading: batchLoading, pagination: batchPagination, sorting: batchSorting, globalFilter: batchFilter }}
|
||||
muiTableBodyRowProps={({ row }) => ({ onClick: () => openView(row.original), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog ref={createDialogRef} className="modal">
|
||||
<form onSubmit={handleBatchSubmit} className="modal-box p-6">
|
||||
<button type="button" onClick={closeCreate} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<h3 className="font-bold text-lg mb-4">New Stock Batch</h3>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="supplierId" className="label"><span className="label-text">Supplier</span></label>
|
||||
<select id="supplierId" name="supplierId" value={batchForm.supplierId ?? ''} onChange={handleBatchInputChange} className="select">
|
||||
<option value="">Select supplier...</option>
|
||||
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="tracking_number" className="label"><span className="label-text">Tracking Number</span></label>
|
||||
<input id="tracking_number" name="tracking_number" type="text" value={batchForm.tracking_number} onChange={handleBatchInputChange} className="input" />
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="arrival_date" className="label"><span className="label-text">Arrival Date</span></label>
|
||||
<input id="arrival_date" name="arrival_date" type="date" value={batchForm.arrival_date} onChange={handleBatchInputChange} className="input" />
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="files" className="label"><span className="label-text">Upload Files</span></label>
|
||||
<input id="files" type="file" multiple onChange={handleBatchFileChange} className="file-input" />
|
||||
</div>
|
||||
|
||||
{batchFiles.map((f, i) => (
|
||||
<div key={i} className="flex items-center mb-2 space-x-2">
|
||||
<span>{f.file.name}</span>
|
||||
<select value={f.fileType} onChange={e => handleBatchFileTypeChange(i, e.target.value as any)} className="select select-sm">
|
||||
<option value="invoice">Invoice</option><option value="label">Label</option><option value="other">Other</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => removeBatchFile(i)} className="btn btn-sm btn-error">Remove</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="modal-action">
|
||||
<button type="submit" className="btn btn-primary">Create Batch</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog ref={viewDialogRef} className="modal">
|
||||
<div className="modal-box max-w-6xl flex space-x-6 p-6 relative">
|
||||
<button type="button" onClick={closeView} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<div className="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>}
|
||||
<div>
|
||||
{/* existing status-history list */}
|
||||
{/*{selectedBatch?.stock_entries?.map((entry) => (*/}
|
||||
{/* <div key={entry.id} style={{ marginBottom: 16 }}>*/}
|
||||
{/* <h4>Stock Entry {entry.id}</h4>*/}
|
||||
{/* <ul>*/}
|
||||
{/* {entry.status_history?.map((history, idx) => (*/}
|
||||
{/* <li key={idx}>{history.status.name}</li>*/}
|
||||
{/* ))}*/}
|
||||
{/* </ul>*/}
|
||||
{/* </div>*/}
|
||||
{/*))}*/}
|
||||
|
||||
{/* ratio displays */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3>Status Ratios</h3>
|
||||
{selectedBatch && statusIds.map((id) => {
|
||||
const { count, total, ratio } = calculateStatusRatio(selectedBatch.stock_entries, id);
|
||||
const status_data = statuses.filter(s => s.id === id)[0];
|
||||
return (
|
||||
<p key={id}>
|
||||
{status_data.name}: {count} / {total} (
|
||||
{(ratio * 100).toFixed(1)}%)
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="tooltip" data-tip="batch QR kod">
|
||||
<button className="btn bg-[#666666] text-[19px]">
|
||||
<FontAwesomeIcon icon={faQrcode} />
|
||||
</button>
|
||||
</div>
|
||||
{hasNonCountedStatus && (
|
||||
<div className="tooltip" data-tip="Prepocitat">
|
||||
<button
|
||||
className="btn btn-warning text-[19px]"
|
||||
onClick={() => {
|
||||
router.get(route('batchCounting', { selectedBatch: selectedBatch.id }));
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faListOl} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/3 flex flex-col">
|
||||
<h3 className="font-bold text-lg mb-2">Stock Entries</h3>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<MaterialReactTable
|
||||
columns={entryColumns}
|
||||
data={entries}
|
||||
manualPagination manualSorting enableGlobalFilter
|
||||
onPaginationChange={setEntriesPagination}
|
||||
onSortingChange={setEntriesSorting}
|
||||
onGlobalFilterChange={setEntriesFilter}
|
||||
rowCount={entriesCount}
|
||||
state={{ isLoading: entriesLoading, pagination: entriesPagination, sorting: entriesSorting, globalFilter: entriesFilter }}
|
||||
muiTableBodyRowProps={({ row }) => ({ onClick: () => openEntry(row.original), style: { cursor: 'pointer' }})}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between">
|
||||
<div>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => openEntry()} className="btn btn-secondary">Edit Items</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog ref={entryDialogRef} className="modal">
|
||||
<form onSubmit={handleEntrySubmit} className={`modal-box flex space-x-4 p-6 ${!editingEntry ? 'max-w-full' : ''}`}>
|
||||
<button type="button" onClick={closeEntry} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
|
||||
{!editingEntry &&
|
||||
<div className="w-2/3">
|
||||
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
|
||||
<MaterialReactTable
|
||||
columns={entryOnTheWayColumns}
|
||||
data={onTheWayEntries.filter(e => e.on_the_way)}
|
||||
manualPagination manualSorting enableGlobalFilter
|
||||
onPaginationChange={setEntriesPagination}
|
||||
onSortingChange={setEntriesSorting}
|
||||
onGlobalFilterChange={setEntriesFilter}
|
||||
rowCount={entriesCount}
|
||||
state={{ isLoading: entriesOnTheWayLoading, pagination: onTheWayEntriesPagination, sorting: onTheWayEntriesSorting, globalFilter: onTheWayEntriesFilter }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={!editingEntry ? 'w-1/3' : 'w-full'}>
|
||||
<h3 className="font-bold text-lg mb-4">{editingEntry ? 'Edit Entry' : 'New Entry'}</h3>
|
||||
|
||||
{!editingEntry &&
|
||||
<div className="form-control mb-4">
|
||||
<Combobox value={entryForm.physical_item_id} onChange={val => setEntryForm(prev => ({ ...prev, physical_item_id: val }))}>
|
||||
<Combobox.Input
|
||||
onChange={e => setItemQuery(e.target.value)}
|
||||
displayValue={id => physicalItems.find(i => i.id === id)?.name || ''}
|
||||
placeholder="Select item..."
|
||||
className="input"
|
||||
/>
|
||||
<Combobox.Options className="dropdown-content menu p-2 shadow bg-base-100 rounded-box max-h-60 overflow-auto">
|
||||
{filteredItems.map(item => (
|
||||
<Combobox.Option key={item.id} value={item.id} className="cursor-pointer p-2 hover:bg-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{item.name}</span>{entryForm.physical_item_id === item.id && <FontAwesomeIcon icon={faCheck} />}
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="supplier_id" className="label"><span className="label-text">Supplier</span></label>
|
||||
<select id="supplier_id" name="supplier_id" value={entryForm.supplier_id ?? ''} onChange={handleEntryInputChange} className="select">
|
||||
<option value="">Select supplier...</option>
|
||||
{suppliers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="form-control">
|
||||
<label htmlFor="count" className="label"><span className="label-text">Count</span></label>
|
||||
<input id="count" name="count" type="number" value={entryForm.count} onChange={handleEntryInputChange} className="input" />
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="price" className="label"><span className="label-text">Price</span></label>
|
||||
<input id="price" name="price" type="number" step="0.01" value={entryForm.price || ''} onChange={handleEntryInputChange} className="input" />
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label htmlFor="bought" className="label"><span className="label-text">Bought Date</span></label>
|
||||
<input id="bought" name="bought" type="date" value={entryForm.bought || ''} onChange={handleEntryInputChange} className="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="description" className="label"><span className="label-text">Description</span></label>
|
||||
<textarea id="description" name="description" value={entryForm.description || ''} onChange={handleEntryInputChange} className="textarea" />
|
||||
</div>
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="note" className="label"><span className="label-text">Note</span></label>
|
||||
<textarea id="note" name="note" value={entryForm.note || ''} onChange={handleEntryInputChange} className="textarea" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 items-end mb-4">
|
||||
<div className="form-control">
|
||||
<label htmlFor="country_of_origin_id" className="label"><span className="label-text">Country</span></label>
|
||||
<select id="country_of_origin_id" name="country_of_origin_id" value={entryForm.country_of_origin_id || ''} onChange={handleEntryInputChange} className="select">
|
||||
<option value="">Select country...</option>
|
||||
{countries.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control flex items-center">
|
||||
<label className="label cursor-pointer"><input type="checkbox" name="on_the_way" checked={entryForm.on_the_way} onChange={handleEntryInputChange} className="checkbox mr-2" /><span className="label-text">On The Way</span></label>
|
||||
</div>
|
||||
<div className="form-control flex items-center">
|
||||
<label className="label cursor-pointer"><input type="checkbox" name="surplus_item" checked={entryForm.surplus_item} onChange={handleEntryInputChange} className="checkbox mr-2" /><span className="label-text">Extra produkt</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 items-end mb-4 border-t-2 border-gray-600 pt-4">
|
||||
{/* Dynamic stock position rows */}
|
||||
<div>
|
||||
Stock positions
|
||||
</div>
|
||||
<div className="space-y-4 mb-4">
|
||||
{positionRows.map((row, idx) => {
|
||||
console.log(row);
|
||||
// filter out positions already used in other rows
|
||||
|
||||
const available = positions.filter(p =>
|
||||
// if we’re editing, show everything; otherwise only un‐occupied slots
|
||||
(editingEntry ? true : !p.occupied)
|
||||
// then still exclude any that are already picked in other rows
|
||||
&& !positionRows.some((r,i) => i !== idx && r.stock_position_id === p.id)
|
||||
);
|
||||
return available.length > 0 ? (
|
||||
<div key={idx} className="grid grid-cols-2 gap-4 items-end">
|
||||
<div className="form-control">
|
||||
<label className="label"><span
|
||||
className="label-text">Position</span></label>
|
||||
<select
|
||||
value={row.stock_position_id || ''}
|
||||
onChange={e => handlePositionRowChange(idx, 'stock_position_id', e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="select"
|
||||
>
|
||||
<option value="">Select position...</option>
|
||||
{available.map(p => <option key={p.id}
|
||||
value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span
|
||||
className="label-text">Count</span></label>
|
||||
<input
|
||||
type="number"
|
||||
value={row.count ?? ''}
|
||||
onChange={e => handlePositionRowChange(idx, 'count', e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
): null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
|
||||
{!editingEntry && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={onTheWayEntriesSelections.length === 0}
|
||||
onClick={handleBatchAddEntries}
|
||||
>
|
||||
Add to batch
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary">{editingEntry ? 'Update Entry' : 'Create Entry'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
369
resources/js/Pages/StockBatch_old.tsx
Normal file
369
resources/js/Pages/StockBatch_old.tsx
Normal file
@ -0,0 +1,369 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AppLayout from '@/Layouts/AppLayout';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
type MRT_ColumnDef,
|
||||
type MRT_PaginationState,
|
||||
type MRT_SortingState,
|
||||
} from 'material-react-table';
|
||||
import axios from 'axios';
|
||||
import {toast, Toaster} from 'react-hot-toast';
|
||||
import type { StockBatch, Supplier } from '@/types';
|
||||
|
||||
interface FileWithType {
|
||||
file: File;
|
||||
fileType: 'invoice' | 'label' | 'other';
|
||||
}
|
||||
|
||||
export default function StockBatches() {
|
||||
const [data, setData] = useState<StockBatch[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
|
||||
const [pagination, setPagination] = useState<MRT_PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [sorting, setSorting] = useState<MRT_SortingState>([
|
||||
{ id: 'updatedAt', desc: true },
|
||||
]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const defaultFormData = {
|
||||
supplierId: null as number | null,
|
||||
tracking_number: '' as string,
|
||||
arrival_date: '' as string,
|
||||
};
|
||||
const [formData, setFormData] = useState(defaultFormData);
|
||||
const [files, setFiles] = useState<FileWithType[]>([]);
|
||||
|
||||
// inside your component, above the useMemo…
|
||||
const formatDate = (value: string, withTime = true) => {
|
||||
const d = new Date(value);
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
if (!withTime) return `${dd}-${mm}-${yyyy}`;
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${dd}-${mm}-${yyyy} ${hh}:${mi}`;
|
||||
};
|
||||
|
||||
const columns = useMemo<MRT_ColumnDef<StockBatch>[]>(() => [
|
||||
{ accessorKey: 'id', header: 'ID', size: 80 },
|
||||
{ accessorKey: 'supplier.name', header: 'Supplier', size: 150 },
|
||||
{ accessorKey: 'tracking_number', header: 'Tracking #', size: 120 },
|
||||
{
|
||||
accessorKey: 'arrival_date',
|
||||
header: 'Arrival Date',
|
||||
size: 120,
|
||||
Cell: ({ cell }) => formatDate(cell.getValue<string>() || '', false),
|
||||
},
|
||||
{
|
||||
accessorFn: row => row.files?.length ?? 0,
|
||||
id: 'files',
|
||||
header: 'Files',
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created At',
|
||||
size: 150,
|
||||
Cell: ({ cell }) => formatDate(cell.getValue<string>() || '', true),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: 'Updated At',
|
||||
size: 150,
|
||||
Cell: ({ cell }) => formatDate(cell.getValue<string>() || '', true),
|
||||
},
|
||||
], []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
|
||||
|
||||
async function fetchSuppliers() {
|
||||
try {
|
||||
const res = await axios.get('/api/stockBatches/options');
|
||||
setSuppliers(res.data.suppliers);
|
||||
} catch {
|
||||
toast.error('Failed to load suppliers');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/stockBatches', {
|
||||
params: {
|
||||
page: pagination.pageIndex + 1,
|
||||
perPage: pagination.pageSize,
|
||||
sortField: sorting[0]?.id,
|
||||
sortOrder: sorting[0]?.desc ? 'desc' : 'asc',
|
||||
filter: globalFilter,
|
||||
},
|
||||
});
|
||||
setData(res.data.data);
|
||||
console.log(res.data.data);
|
||||
setRowCount(res.data.meta.total);
|
||||
} catch {
|
||||
toast.error('Failed to load batches');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
dialogRef.current?.showModal();
|
||||
}
|
||||
function closeModal() {
|
||||
dialogRef.current?.close();
|
||||
setFormData(defaultFormData);
|
||||
setFiles([]);
|
||||
}
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]:
|
||||
name === 'supplierId'
|
||||
? value
|
||||
? parseInt(value)
|
||||
: null
|
||||
: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(
|
||||
Array.from(e.target.files).map(file => ({
|
||||
file,
|
||||
fileType: 'other' as const,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileTypeChange = (
|
||||
index: number,
|
||||
fileType: FileWithType['fileType']
|
||||
) => {
|
||||
setFiles(prev =>
|
||||
prev.map((f, i) => (i === index ? { ...f, fileType } : f))
|
||||
);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (formData.supplierId !== null) {
|
||||
fd.append('supplier_id', formData.supplierId.toString());
|
||||
}
|
||||
if (formData.tracking_number) {
|
||||
fd.append('tracking_number', formData.tracking_number);
|
||||
}
|
||||
if (formData.arrival_date) {
|
||||
fd.append('arrival_date', formData.arrival_date);
|
||||
}
|
||||
files.forEach(({ file, fileType }, i) => {
|
||||
fd.append(`files[${i}]`, file);
|
||||
fd.append(`file_types[${i}]`, fileType);
|
||||
});
|
||||
|
||||
await axios.post('/api/stockBatches', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
toast.success('Batch created');
|
||||
closeModal();
|
||||
fetchData();
|
||||
// inside your catch block…
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
// if it’s a 422 validation error
|
||||
if (error.response?.status === 422 && error.response.data.errors) {
|
||||
const errs = error.response.data.errors as Record<string, string[]>;
|
||||
// flat-map all the messages and toast each one
|
||||
Object.values(errs)
|
||||
.flat()
|
||||
.forEach(msg => toast.error(msg, {
|
||||
duration: 5000,
|
||||
}));
|
||||
} else {
|
||||
// some other HTTP error
|
||||
toast.error(error.response?.statusText || 'Unknown error', {
|
||||
duration: 5000,
|
||||
});
|
||||
toast.error(error.response?.data?.errors || '', {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// non-Axios error
|
||||
toast.error('Failed to create batch');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
title="Stock Batches"
|
||||
renderHeader={() => (
|
||||
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
Stock Batches
|
||||
</h2>
|
||||
)}
|
||||
>
|
||||
<Head title="Stock Batches" />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
/>
|
||||
<div className="py-12">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Batches</h1>
|
||||
<button onClick={openModal} className="btn btn-primary">
|
||||
Add New Batch
|
||||
</button>
|
||||
</div>
|
||||
<MaterialReactTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
manualPagination
|
||||
manualSorting
|
||||
enableGlobalFilter
|
||||
onPaginationChange={setPagination}
|
||||
onSortingChange={setSorting}
|
||||
onGlobalFilterChange={setGlobalFilter}
|
||||
rowCount={rowCount}
|
||||
state={{ isLoading, pagination, sorting, globalFilter }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog ref={dialogRef} className="modal">
|
||||
<form onSubmit={handleSubmit} className="modal-box p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<h3 className="font-bold text-lg mb-4">New Stock Batch</h3>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="supplierId" className="label">
|
||||
<span className="label-text">Supplier</span>
|
||||
</label>
|
||||
<select
|
||||
id="supplierId"
|
||||
name="supplierId"
|
||||
value={formData.supplierId ?? ''}
|
||||
onChange={handleInputChange}
|
||||
className="select"
|
||||
>
|
||||
<option value="">Select supplier...</option>
|
||||
{suppliers.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="tracking_number" className="label">
|
||||
<span className="label-text">Tracking Number</span>
|
||||
</label>
|
||||
<input
|
||||
id="tracking_number"
|
||||
name="tracking_number"
|
||||
type="text"
|
||||
value={formData.tracking_number}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="arrival_date" className="label">
|
||||
<span className="label-text">Arrival Date</span>
|
||||
</label>
|
||||
<input
|
||||
id="arrival_date"
|
||||
name="arrival_date"
|
||||
type="date"
|
||||
value={formData.arrival_date}
|
||||
onChange={handleInputChange}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control mb-4">
|
||||
<label htmlFor="files" className="label">
|
||||
<span className="label-text">Upload Files</span>
|
||||
</label>
|
||||
<input
|
||||
id="files"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="file-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.map((f, idx) => (
|
||||
<div key={idx} className="flex items-center mb-2 space-x-2">
|
||||
<span>{f.file.name}</span>
|
||||
<select
|
||||
value={f.fileType}
|
||||
onChange={e =>
|
||||
handleFileTypeChange(idx, e.target.value as FileWithType['fileType'])
|
||||
}
|
||||
className="select select-sm"
|
||||
>
|
||||
<option value="invoice">Invoice</option>
|
||||
<option value="label">Label</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="btn btn-sm btn-error"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="modal-action">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create Batch
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
@ -34,13 +34,11 @@ interface StockEntry {
|
||||
stock_batch_id: number | null;
|
||||
created_by?: number;
|
||||
updated_by?: number;
|
||||
// Related objects
|
||||
physical_item?: DropdownOption;
|
||||
supplier?: DropdownOption;
|
||||
stock_position?: { id: number; line: string; rack: string; shelf: string; position: string };
|
||||
}
|
||||
|
||||
// Form data mirrors the model fields
|
||||
interface StockEntryFormData {
|
||||
physical_item_id: number | null;
|
||||
supplier_id: number | null;
|
||||
@ -55,23 +53,7 @@ interface StockEntryFormData {
|
||||
stock_batch_id: number | null;
|
||||
}
|
||||
|
||||
export default function StockEntries() {
|
||||
// Table state
|
||||
const [data, setData] = useState<StockEntry[]>([]);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefetching, setIsRefetching] = useState(false);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
|
||||
// Options for dropdowns
|
||||
const [stockPositions, setStockPositions] = useState<DropdownOption[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<DropdownOption[]>([]);
|
||||
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
|
||||
const [originCountries, setOriginCountries] = useState<DropdownOption[]>([]);
|
||||
|
||||
// Modal & form state
|
||||
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
|
||||
const [formData, setFormData] = useState<StockEntryFormData>({
|
||||
const defaultForm = {
|
||||
physical_item_id: null,
|
||||
supplier_id: null,
|
||||
count: 0,
|
||||
@ -83,13 +65,34 @@ export default function StockEntries() {
|
||||
country_of_origin_id: null,
|
||||
on_the_way: false,
|
||||
stock_batch_id: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Combobox search state
|
||||
export default function StockEntries() {
|
||||
// Table state
|
||||
const [data, setData] = useState<StockEntry[]>([]);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefetching, setIsRefetching] = useState(false);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
|
||||
// Dropdowns
|
||||
const [stockPositions, setStockPositions] = useState<DropdownOption[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<DropdownOption[]>([]);
|
||||
const [physicalItems, setPhysicalItems] = useState<DropdownOption[]>([]);
|
||||
const [originCountries, setOriginCountries] = useState<DropdownOption[]>([]);
|
||||
|
||||
// Modal/form state
|
||||
const [editingEntry, setEditingEntry] = useState<StockEntry | null>(null);
|
||||
const [formData, setFormData] = useState<StockEntryFormData>(defaultForm);
|
||||
|
||||
// Batch mode state
|
||||
const [isBatchMode, setIsBatchMode] = useState(false);
|
||||
const [batchSelections, setBatchSelections] = useState<number[]>([]);
|
||||
|
||||
// Combobox search
|
||||
const [itemQuery, setItemQuery] = useState('');
|
||||
const filteredItems = useMemo(
|
||||
() =>
|
||||
itemQuery === ''
|
||||
() => itemQuery === ''
|
||||
? physicalItems
|
||||
: physicalItems.filter(item =>
|
||||
item.name.toLowerCase().includes(itemQuery.toLowerCase()),
|
||||
@ -97,7 +100,7 @@ export default function StockEntries() {
|
||||
[itemQuery, physicalItems],
|
||||
);
|
||||
|
||||
// Pagination, sorting, filtering
|
||||
// Pagination/sorting/filtering
|
||||
const [pagination, setPagination] = useState<MRT_PaginationState>({ pageIndex: 0, pageSize: 10 });
|
||||
const [sorting, setSorting] = useState<MRT_SortingState>([{ id: 'updated_at', desc: true }]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
@ -107,7 +110,7 @@ export default function StockEntries() {
|
||||
const openModal = () => dialogRef.current?.showModal();
|
||||
const closeModal = () => dialogRef.current?.close();
|
||||
|
||||
// Column definitions
|
||||
// Columns
|
||||
const columns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
||||
() => [
|
||||
{ accessorKey: 'id', header: 'ID', size: 80 },
|
||||
@ -128,153 +131,160 @@ export default function StockEntries() {
|
||||
[],
|
||||
);
|
||||
|
||||
// Load dropdown options (except items)
|
||||
const fetchOptions = async () => {
|
||||
// Batch columns
|
||||
const batchColumns = useMemo<MRT_ColumnDef<StockEntry>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'select',
|
||||
header: 'Select',
|
||||
Cell: ({ row }) => {
|
||||
const id = row.original.id;
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batchSelections.includes(id)}
|
||||
onChange={() => setBatchSelections(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ accessorKey: 'id', header: 'ID' },
|
||||
{ accessorKey: 'physical_item.name', header: 'Item' },
|
||||
{ accessorKey: 'supplier.name', header: 'Supplier' },
|
||||
],
|
||||
[batchSelections],
|
||||
);
|
||||
|
||||
// Fetch options & data
|
||||
useEffect(() => { fetchData(); }, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
|
||||
useEffect(() => { fetchOptions(); }, []);
|
||||
|
||||
async function fetchOptions() {
|
||||
try {
|
||||
const response = await axios.get('/api/stockData/options');
|
||||
setStockPositions(response.data.stockPositions);
|
||||
setSuppliers(response.data.suppliers);
|
||||
setOriginCountries(response.data.countriesOrigin);
|
||||
console.log("data all");
|
||||
console.log(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching options', error);
|
||||
const { data } = await axios.get('/api/stockData/options');
|
||||
setStockPositions(data.stockPositions);
|
||||
setSuppliers(data.suppliers);
|
||||
setOriginCountries(data.countriesOrigin);
|
||||
} catch {
|
||||
toast.error('Failed to load form options');
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch filtered items
|
||||
const fetchPhysicalItems = async (query: string) => {
|
||||
if (!query) return;
|
||||
try {
|
||||
const response = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
|
||||
setPhysicalItems(response.data.physicalItems);
|
||||
console.log("physical items");
|
||||
console.log(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching items', error);
|
||||
toast.error('Failed to load form items');
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce itemQuery changes
|
||||
useEffect(() => {
|
||||
const delayDebounce = setTimeout(() => {
|
||||
fetchPhysicalItems(itemQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(delayDebounce);
|
||||
}, [itemQuery]);
|
||||
|
||||
// Fetch table data
|
||||
const fetchData = async () => {
|
||||
if (!data.length) setIsLoading(true);
|
||||
else setIsRefetching(true);
|
||||
async function fetchData() {
|
||||
setIsLoading(!data.length);
|
||||
setIsRefetching(!!data.length);
|
||||
try {
|
||||
const response = await axios.get('/api/stockData');
|
||||
setData(response.data.data);
|
||||
console.log(response.data.data);
|
||||
setRowCount(response.data.meta.total);
|
||||
} catch (error) {
|
||||
const res = await axios.get('/api/stockData');
|
||||
setData(res.data.data);
|
||||
setRowCount(res.data.meta.total);
|
||||
} catch {
|
||||
setIsError(true);
|
||||
console.error(error);
|
||||
toast.error('Failed to fetch stock entries');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form input changes
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
if (type === 'checkbox') {
|
||||
setFormData(prev => ({ ...prev, [name]: (e.target as HTMLInputElement).checked }));
|
||||
} else if (type === 'number') {
|
||||
setFormData(prev => ({ ...prev, [name]: value ? parseFloat(value) : null }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value || null }));
|
||||
}
|
||||
|
||||
async function fetchPhysicalItems(query: string) {
|
||||
try {
|
||||
const res = await axios.get('/api/stockData/options/items', { params: { item_name: query } });
|
||||
setPhysicalItems(res.data.physicalItems);
|
||||
} catch {
|
||||
toast.error('Failed to load items');
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
const delay = setTimeout(() => itemQuery && fetchPhysicalItems(itemQuery), 500);
|
||||
return () => clearTimeout(delay);
|
||||
}, [itemQuery]);
|
||||
|
||||
// Input change
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox'
|
||||
? (e.target as HTMLInputElement).checked
|
||||
: type === 'number'
|
||||
? parseFloat(value) || null
|
||||
: value || null,
|
||||
}));
|
||||
};
|
||||
|
||||
// Submit form
|
||||
// Submit
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
let res;
|
||||
if (editingEntry) {
|
||||
await axios.put(`/api/stockData/${editingEntry.id}`, formData);
|
||||
toast.success('Stock entry updated successfully');
|
||||
res = await axios.put(`/api/stockData/${editingEntry.id}`, formData);
|
||||
toast.success('Stock entry updated');
|
||||
} else {
|
||||
await axios.post('/api/stockData', formData);
|
||||
toast.success('Stock entry created successfully');
|
||||
res = await axios.post('/api/stockData', formData);
|
||||
toast.success('Stock entry created');
|
||||
}
|
||||
const newEntry: StockEntry = res.data;
|
||||
if (isBatchMode) {
|
||||
// Add to batch and keep modal open
|
||||
setBatchSelections(prev => [...prev, newEntry.id]);
|
||||
setFormData({ ...defaultForm, on_the_way: true });
|
||||
} else {
|
||||
closeModal();
|
||||
}
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error submitting form', error);
|
||||
toast.error('Failed to save stock entry');
|
||||
} catch {
|
||||
toast.error('Failed to save');
|
||||
}
|
||||
};
|
||||
|
||||
// Batch submit
|
||||
const handleBatchSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post('/api/stockData/batch', { ids: batchSelections });
|
||||
toast.success('Batch created');
|
||||
closeModal();
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error('Batch submit failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Edit/Delete handlers
|
||||
const handleEdit = (entry: StockEntry) => {
|
||||
setEditingEntry(entry);
|
||||
setFormData({
|
||||
physical_item_id: entry.physical_item_id,
|
||||
supplier_id: entry.supplier_id,
|
||||
count: entry.count,
|
||||
price: entry.price,
|
||||
bought: entry.bought,
|
||||
description: entry.description,
|
||||
note: entry.note,
|
||||
stock_position_id: entry.stock_position_id,
|
||||
country_of_origin_id: entry.country_of_origin_id,
|
||||
on_the_way: entry.on_the_way,
|
||||
stock_batch_id: entry.stock_batch_id,
|
||||
});
|
||||
setFormData({ ...entry });
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this entry?')) return;
|
||||
if (!confirm('Are you sure?')) return;
|
||||
try {
|
||||
await axios.delete(`/api/stock-entries/${id}`);
|
||||
toast.success('Stock entry deleted successfully');
|
||||
await axios.delete(`/api/stockData/${id}`);
|
||||
toast.success('Deleted');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting entry', error);
|
||||
toast.error('Failed to delete stock entry');
|
||||
} catch {
|
||||
toast.error('Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
// Add new
|
||||
const handleAdd = () => {
|
||||
setIsBatchMode(false);
|
||||
setEditingEntry(null);
|
||||
setFormData({
|
||||
physical_item_id: null,
|
||||
supplier_id: null,
|
||||
count: 0,
|
||||
price: null,
|
||||
bought: null,
|
||||
description: null,
|
||||
note: null,
|
||||
stock_position_id: null,
|
||||
country_of_origin_id: null,
|
||||
on_the_way: false,
|
||||
stock_batch_id: null,
|
||||
});
|
||||
setFormData(defaultForm);
|
||||
openModal();
|
||||
};
|
||||
|
||||
// handleNewBatch
|
||||
const handleNewBatch = () => {
|
||||
|
||||
setIsBatchMode(true);
|
||||
setEditingEntry(null);
|
||||
setBatchSelections([]);
|
||||
setFormData({ ...defaultForm, on_the_way: true });
|
||||
openModal();
|
||||
};
|
||||
|
||||
useEffect(() => { fetchData(); }, [pagination.pageIndex, pagination.pageSize, sorting, globalFilter]);
|
||||
useEffect(() => { fetchOptions(); }, []);
|
||||
|
||||
return (
|
||||
<AppLayout title="Stock Entries" renderHeader={() => (
|
||||
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
||||
@ -288,10 +298,9 @@ export default function StockEntries() {
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Stock Entries</h1>
|
||||
<div>
|
||||
<button className="btn" onClick={handleNewBatch}>New batch</button>
|
||||
<button className="btn mr-2" onClick={handleNewBatch}>New batch</button>
|
||||
<button className="btn" onClick={handleAdd}>Add New Entry</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<MaterialReactTable
|
||||
columns={columns}
|
||||
@ -315,13 +324,25 @@ export default function StockEntries() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog ref={dialogRef} id="add_new_modal" className="modal">
|
||||
<form onSubmit={handleSubmit} className="modal-box space-y-4">
|
||||
{/* Modal (Add/Edit & Batch) */}
|
||||
<dialog ref={dialogRef} className="modal">
|
||||
<form onSubmit={handleSubmit} className={`modal-box flex space-x-4 p-6 ${isBatchMode ? 'max-w-full' : ''}`}>
|
||||
<button type="button" onClick={closeModal} className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<h3 className="font-bold text-lg">
|
||||
{editingEntry ? 'Edit Stock Entry' : 'New Stock Entry'}
|
||||
</h3>
|
||||
|
||||
{isBatchMode && (
|
||||
<div className="w-2/3">
|
||||
<h3 className="font-bold text-lg mb-2">Select Incoming Items</h3>
|
||||
<MaterialReactTable
|
||||
columns={batchColumns}
|
||||
data={data.filter(e => e.on_the_way)}
|
||||
enablePagination={false}
|
||||
enableSorting={false}
|
||||
enableGlobalFilter={false}
|
||||
getRowId={row => row.id.toString()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={isBatchMode ? 'w-1/3' : 'w-full'}>
|
||||
<h3 className="font-bold text-lg mb-4">{isBatchMode ? 'New Batch Entry' : (editingEntry ? 'Edit Stock Entry' : 'New Stock Entry')}</h3>
|
||||
{/* Physical Item */}
|
||||
<div className="form-control">
|
||||
<Combobox value={formData.physical_item_id} onChange={val => setFormData(prev => ({ ...prev, physical_item_id: val }))}>
|
||||
@ -408,10 +429,22 @@ export default function StockEntries() {
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="modal-action">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{editingEntry ? 'Update Entry' : 'Create Entry'}
|
||||
<div className="modal-action flex justify-between">
|
||||
{isBatchMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={batchSelections.length === 0}
|
||||
onClick={handleBatchSubmit}
|
||||
>
|
||||
Create Batch
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{isBatchMode ? 'Create Entry' : (editingEntry ? 'Update Entry' : 'Create Entry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
@ -94,3 +94,341 @@ export interface TeamInvitation {
|
||||
created_at: DateTime;
|
||||
updated_at: DateTime;
|
||||
}
|
||||
|
||||
export interface StockHandling {
|
||||
id: number;
|
||||
physicalItemId: number;
|
||||
fieldUpdated: string;
|
||||
valuePrev: string;
|
||||
valueNew: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
physicalItem?: PhysicalItem;
|
||||
}
|
||||
|
||||
export interface StockPosition {
|
||||
id: number;
|
||||
line: string;
|
||||
rack: string;
|
||||
shelf: string;
|
||||
position: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface StockBatch {
|
||||
id: number;
|
||||
user_id: number;
|
||||
supplier_id: number | null;
|
||||
tracking_number: number | null;
|
||||
arrival_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
user?: User;
|
||||
supplier?: Supplier;
|
||||
files?: StockBatchFile[];
|
||||
statusHistory?: StockBatchStatusHistory[];
|
||||
stock_entries?: StockEntry[];
|
||||
}
|
||||
|
||||
|
||||
export interface StockEntry {
|
||||
id: number;
|
||||
physical_item_id: number;
|
||||
supplier_id: number;
|
||||
count: number;
|
||||
price: number | null;
|
||||
bought: string | null;
|
||||
description: string | null;
|
||||
note: string | null;
|
||||
country_of_origin_id: number;
|
||||
on_the_way: boolean;
|
||||
stock_batch_id: number | null;
|
||||
created_by: number;
|
||||
updated_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
physicalItem?: PhysicalItem;
|
||||
supplier?: Supplier;
|
||||
stockBatch?: StockBatch;
|
||||
sections?: (StockSection & { pivot: { count: number; created_at: string; updated_at: string | null } })[];
|
||||
countryOfOrigin?: CountryOfOrigin;
|
||||
}
|
||||
|
||||
export interface StockAttribute {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
translations?: StockAttributeTranslation[];
|
||||
values?: StockAttributeValue[];
|
||||
}
|
||||
|
||||
export interface StockAttributeTranslation {
|
||||
stockAttributeId: number;
|
||||
languageId: number;
|
||||
translatedName: string;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
attribute?: StockAttribute;
|
||||
}
|
||||
|
||||
export interface StockAttributeValue {
|
||||
id: number;
|
||||
stockAttributeId: number;
|
||||
name: string;
|
||||
languageId: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
attribute?: StockAttribute;
|
||||
}
|
||||
|
||||
export interface StockEntryAttribute {
|
||||
stockAttributeId: number;
|
||||
stockAttributeValueId: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
attribute?: StockAttribute;
|
||||
value?: StockAttributeValue;
|
||||
}
|
||||
|
||||
export interface StockBatchFile {
|
||||
id: number;
|
||||
filename: string;
|
||||
fileData: string;
|
||||
fileType: 'invoice' | 'label' | 'other';
|
||||
stockBatchId: number;
|
||||
userId: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
batch?: StockBatch;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface StockBatchStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
history?: StockBatchStatusHistory[];
|
||||
}
|
||||
|
||||
export interface StockBatchStatusHistory {
|
||||
id: number;
|
||||
stockBatchId: number;
|
||||
stockBatchStatusId: number;
|
||||
statusNote: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
batch?: StockBatch;
|
||||
status?: StockBatchStatus;
|
||||
}
|
||||
|
||||
export interface StockEntriesStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
history?: StockEntriesStatusHistory[];
|
||||
}
|
||||
|
||||
export interface StockEntriesStatusHistory {
|
||||
id: number;
|
||||
stockEntriesId: number;
|
||||
stockEntriesStatusId: number;
|
||||
statusNote: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
entry?: StockEntry;
|
||||
status?: StockEntriesStatus;
|
||||
}
|
||||
|
||||
// --- Missing Interfaces ---
|
||||
|
||||
export interface PhysicalItem {
|
||||
id: number;
|
||||
name: string;
|
||||
_name: string;
|
||||
physicalItemTypeId: number;
|
||||
manufacturerId: number;
|
||||
inStock: number;
|
||||
blocked: number;
|
||||
sellingPrice: number | null;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
createdBy: number;
|
||||
updated_at: string;
|
||||
updatedBy: number | null;
|
||||
|
||||
// relations
|
||||
// physicalItemType?: PhysicalItemType;
|
||||
// manufacturer?: Supplier;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerifiedAt: string | null;
|
||||
password: string;
|
||||
twoFactorSecret: string | null;
|
||||
twoFactorRecoveryCodes: string | null;
|
||||
twoFactorConfirmedAt: string | null;
|
||||
rememberToken: string | null;
|
||||
currentTeamId: number | null;
|
||||
profilePhotoPath: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
currentTeam?: Team;
|
||||
}
|
||||
|
||||
export interface Supplier {
|
||||
id: number;
|
||||
name: string;
|
||||
_name: string;
|
||||
description: string | null;
|
||||
extId: number | null;
|
||||
created_at: string;
|
||||
createdBy: number;
|
||||
updated_at: string;
|
||||
updatedBy: number | null;
|
||||
|
||||
// relations
|
||||
// (e.g., batches?: StockBatch[])
|
||||
}
|
||||
|
||||
export interface CountryOfOrigin {
|
||||
id: number;
|
||||
code: string;
|
||||
created_at: string;
|
||||
createdBy: number;
|
||||
updated_at: string;
|
||||
updatedBy: number | null;
|
||||
|
||||
// relations
|
||||
entries?: StockEntry[];
|
||||
}
|
||||
|
||||
|
||||
export interface StockRoom {
|
||||
room_id: number;
|
||||
room_symbol: string;
|
||||
room_name: string;
|
||||
created_at: string; // ISO datetime string
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
lines?: StockLine[];
|
||||
}
|
||||
|
||||
export interface StockLine {
|
||||
line_id: number;
|
||||
line_symbol: string;
|
||||
line_name: string;
|
||||
room_id: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
room?: StockRoom;
|
||||
racks?: StockRack[];
|
||||
}
|
||||
|
||||
export interface StockRack {
|
||||
rack_id: number;
|
||||
rack_symbol: string;
|
||||
rack_name: string;
|
||||
line_id: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
line?: StockLine;
|
||||
shelves?: StockShelf[];
|
||||
}
|
||||
|
||||
export interface StockShelf {
|
||||
shelf_id: number;
|
||||
shelf_symbol: string;
|
||||
shelf_name: string;
|
||||
rack_id: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
rack?: StockRack;
|
||||
positions?: StockPosition[];
|
||||
}
|
||||
|
||||
export interface StockPosition {
|
||||
position_id: number;
|
||||
position_symbol: string;
|
||||
position_name: string;
|
||||
shelf_id: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
shelf?: StockShelf;
|
||||
sections?: StockSection[];
|
||||
}
|
||||
|
||||
export interface StockSection {
|
||||
section_id: number;
|
||||
section_symbol: string;
|
||||
section_name: string;
|
||||
position_id: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
occupied: boolean | null,
|
||||
|
||||
// relations
|
||||
position?: StockPosition;
|
||||
/**
|
||||
* If you eager‐load entries via the pivot,
|
||||
* you'll get full StockEntry objects here,
|
||||
* each with an attached pivot count.
|
||||
*/
|
||||
entries?: (StockEntry & { pivot: { count: number; created_at: string; updated_at: string | null } })[];
|
||||
}
|
||||
|
||||
export interface StockEntries2Section {
|
||||
entry_id: number;
|
||||
section_id: number;
|
||||
count: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
|
||||
// relations
|
||||
section?: StockSection;
|
||||
entry?: StockEntry;
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\StockBatchController;
|
||||
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 Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\StockEntryController;
|
||||
|
||||
Route::get('/user', function (Request $request) {
|
||||
return $request->user();
|
||||
@ -13,16 +17,81 @@ Route::get('/user', function (Request $request) {
|
||||
Route::get('/stockData/options', [StockEntryController::class, 'getOptions']);
|
||||
Route::get('/stockData/options/items', [StockEntryController::class, 'getItems']);
|
||||
Route::get('stockData', [StockEntryController::class, 'index']);
|
||||
Route::get('stockData/audit/{id}', [StockEntryController::class, 'audit']);
|
||||
Route::get('stockDataOnTheWay', [StockEntryController::class, 'fetchOnTheWay']);
|
||||
Route::post('stockData', [StockEntryController::class, 'addData']);
|
||||
Route::put('stockData/{id}', [StockEntryController::class, 'updateData']);
|
||||
|
||||
Route::get('stockBatches', [StockBatchController::class, 'index']);
|
||||
Route::post('stockBatches', [StockBatchController::class, 'addData']);
|
||||
Route::put('stockBatches/{id}', [StockBatchController::class, 'updateData']);
|
||||
Route::get('stockBatches/{id}/entries', [StockBatchController::class, 'getEntries']);
|
||||
Route::put('stockBatches/{id}/entries', [StockBatchController::class, 'addEntries']);
|
||||
|
||||
Route::get('/stockBatches/options', [StockBatchController::class, 'getOptions']);
|
||||
|
||||
// Additional stock management endpoints
|
||||
Route::get('/stock-positions', [StockEntryController::class, 'getStockPositions']);
|
||||
Route::get('/physical-items', [StockEntryController::class, 'getPhysicalItems']);
|
||||
Route::get('/suppliers', [StockEntryController::class, 'getSuppliers']);
|
||||
|
||||
|
||||
// Batch operations
|
||||
Route::post('/stock-entries/batch-update', [StockEntryController::class, 'batchUpdate']);
|
||||
Route::post('/stock-entries/batch-delete', [StockEntryController::class, 'batchDelete']);
|
||||
|
||||
|
||||
|
||||
Route::get('/stockStatusList', [StockEntryController::class, 'getStatusList']);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// barcode scanner methods
|
||||
|
||||
Route::post('stockActions/itemPickup', [StockEntryController::class, 'itemPickup']);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Route::post('expediceActions/itemPickup', [StockHandleExpediceController::class, 'updateSectionCount']);
|
||||
|
||||
|
||||
// expedice akce
|
||||
|
||||
// nacteni produktu - itemPickup,
|
||||
// prepocitat (jen req, expedice zadava pocet na kontrolu),
|
||||
// neni kde ma byt,
|
||||
// je kdyz nema byt,
|
||||
// scan produktu co se vratily
|
||||
// status packed - tisk faktury
|
||||
// pridat novou nahradu, poslat jiny ovladac nez je povoleno / poslat na zkousku
|
||||
// vybrat jake lze dat baterky, model (zase na req)
|
||||
// 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
|
||||
// naskenuje paletu, odveze na misto "k napocitani"
|
||||
// ten kdo pocita, vezme krabici, naskenuje, vytahne obsah, prepocita, v systemu potvrdi pocet.
|
||||
// krabice muze cekat tak, jak je, na naskladneni - az na skladovem miste lze odepisovat a brat (expedice)
|
||||
// jakmile je potvrzen pocet, expedice z krabice muze brat i mimo skladove misto - na docasnem skladovem miste (tam budou jen napocitane produkty)
|
||||
// bude fungovat stejne jako bezne skladove misto, ale bude u dominika nekde bokem. (nektere skladove mista budou oznacene, jako ze se z nich brat nesmi - napr "k napocitani")
|
||||
|
||||
// pri naskladnovani postupuju dvema zpusoby
|
||||
// 1. naskenuju batch - vyberu produkt, naskladnim na urcite skladove misto (vyberu scanem), urcity pocet (vyberu cislem)
|
||||
// 2. naskenuju skladove misto (kde lze uz odepisovat), zde pouze menim skladove misto - naskenuju puvodni, zmenit, vyberu nove, potvrdim scanem noveho
|
||||
// dominik pri zpracovani batche uvidi docasne vazby (posilejte originaly dokud XYZ), tu musi zrusit pri naskladnovani
|
||||
|
||||
|
||||
// rozbaleno produkty - prozkoumat
|
||||
|
||||
Route::post('/stockActions/{stockEntry}/status', [StockEntryStatusController::class, 'store']);
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StockBatch;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
@ -26,4 +28,32 @@ Route::middleware([
|
||||
Route::get('/stock', function () {
|
||||
return Inertia::render('StockEntries');
|
||||
})->name('stock');
|
||||
|
||||
// Stock Entries routes
|
||||
Route::get('/batches', function () {
|
||||
return Inertia::render('StockBatch');
|
||||
})->name('batches');
|
||||
|
||||
// Stock Entries routes
|
||||
Route::get('/batchCounting', function (Request $request) {
|
||||
return Inertia::render('BatchCounting', [
|
||||
'selectedBatch' => StockBatch::with(
|
||||
[
|
||||
'supplier',
|
||||
'stockEntries.physicalItem',
|
||||
'stockEntries.supplier',
|
||||
// 'stockEntries.attributes',
|
||||
'stockEntries.sections',
|
||||
'stockEntries.statusHistory',
|
||||
'files'
|
||||
])->findOrFail($request->get('selectedBatch')),
|
||||
]);
|
||||
})->name('batchCounting');
|
||||
|
||||
|
||||
// Stock Entries routes
|
||||
Route::get('/pdaView', function () {
|
||||
return Inertia::render('PdaView');
|
||||
})->name('pdaView');
|
||||
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user