summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLester Caine <lester@lsces.co.uk>2026-06-08 20:56:56 +0100
committerLester Caine <lester@lsces.co.uk>2026-06-08 20:56:56 +0100
commita838a2d3963738b2e9bc496b31e078ead79d4a43 (patch)
treebafe061903e4a076b92eb6d00813661f21f64ae8
parent5e0af09c649b90a0963bd4e6b62f24b8cbf8edd9 (diff)
downloadstock-a838a2d3963738b2e9bc496b31e078ead79d4a43.tar.gz
stock-a838a2d3963738b2e9bc496b31e078ead79d4a43.tar.bz2
stock-a838a2d3963738b2e9bc496b31e078ead79d4a43.zip
stock: requisition system, movement BOM editor, autocomplete dropdowns
Requisitions: - add_requisition.php: create REQN movements linked to assemblies or kitlocker components; Ordered date stored on REQN xref start_date - ASSEMBLY xref group registered in schema (sort_order=1, template=assembly); ASSEMBLY item registered with multiple=1 for future multi-item reqns - StockMovement::loadXrefInfo() enriches assembly group rows with linked_title/linked_desc from liberty_content - view_xref_assembly_group/item templates: Assembly tab with qty, title link, type; Add item form on edit_movement for REQNs; hidden on I-direction - view_movement/edit_movement: isReqn flag hides assembly tab and type selector on ORDER/TRANS; add another assembly from edit_movement BOM editor: - stockmovement/view_xref_bom_group: Add component links to new add_movement_component.php instead of generic liberty/add_xref.php - add_movement_component.php/.tpl: movement-aware add component page; uses movement_lookup_inc, STOCKMOVEMENT_CONTENT_TYPE_GUID, sequential xorder, redirects back to edit_movement Autocomplete dropdowns (replace <select> everywhere): - add_requisition: hidden+text+dropdown widget; KLID in JSON for matching - add_supplier: AJAX contact lookup replaces static supplier list - list_stock: assembly selector replaced with autocomplete dropdown - edit_movement: From field proper dropdown replacing datalist; Movement Type radio fixed (was reading non-existent mInfo[reference]) - view_xref_assembly_group: Add item inline form with same widget Import: - ImportKitlockerAssemblies: upsert xrefs on reload; KLID/KLSGL/KL3M now stored on both assemblies and components (not assembly-only) - StockComponent::getList: kitlocker_only filter via EXISTS(KLID xref) list_movements: Add Requisition icon alongside Add Movement in header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--add_movement_component.php85
-rw-r--r--add_requisition.php104
-rw-r--r--add_supplier.php7
-rwxr-xr-xadmin/schema_inc.php4
-rw-r--r--edit_movement.php74
-rw-r--r--import/ImportKitlockerAssemblies.php110
-rwxr-xr-xincludes/classes/StockComponent.php4
-rw-r--r--includes/classes/StockMovement.php17
-rw-r--r--list_stock.php18
-rw-r--r--templates/add_movement_component.tpl113
-rw-r--r--templates/add_requisition.tpl90
-rw-r--r--templates/add_supplier.tpl67
-rw-r--r--templates/edit_movement.tpl107
-rw-r--r--templates/list_movements.tpl1
-rw-r--r--templates/list_stock.tpl59
-rw-r--r--templates/stockmovement/view_xref_bom_group.tpl2
-rw-r--r--templates/view_movement.tpl2
-rw-r--r--templates/view_xref_assembly_group.tpl113
-rw-r--r--templates/view_xref_assembly_item.tpl24
-rw-r--r--view_movement.php1
20 files changed, 848 insertions, 154 deletions
diff --git a/add_movement_component.php b/add_movement_component.php
new file mode 100644
index 0000000..9f2dfa2
--- /dev/null
+++ b/add_movement_component.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Add a single component line to a stock movement BOM.
+ * Same flow as add_component.php but for stockmovement content.
+ *
+ * @package stock
+ */
+
+namespace Bitweaver\Stock;
+
+use Bitweaver\KernelTools;
+use Bitweaver\Liberty\LibertyXref;
+
+require_once '../kernel/includes/setup_inc.php';
+
+global $gBitSystem, $gBitSmarty, $gBitDb;
+
+include_once STOCK_PKG_INCLUDE_PATH.'movement_lookup_inc.php';
+
+if( !$gContent->isValid() ) {
+ $gBitSystem->fatalError( 'No valid movement specified.' );
+}
+$gContent->verifyUpdatePermission();
+
+$validItems = [ 'SGL' => 'Single unit', 'PCK' => 'Pack', 'SHT' => 'Sheet (H x W)', 'VOL' => 'Volume' ];
+$errors = [];
+
+if( !empty( $_REQUEST['fCancel'] ) ) {
+ header( 'Location: '.STOCK_PKG_URL.'edit_movement.php?content_id='.$gContent->mContentId );
+ die;
+}
+
+if( !empty( $_REQUEST['fAddComponent'] ) ) {
+ $title = trim( $_REQUEST['component_title'] ?? '' );
+ $item = strtoupper( trim( $_REQUEST['item'] ?? 'SGL' ) );
+ $xkey = trim( $_REQUEST['xkey'] ?? '' );
+
+ if( !array_key_exists( $item, $validItems ) ) {
+ $item = 'SGL';
+ }
+
+ if( $title === '' ) {
+ $errors[] = KernelTools::tra( 'Component title is required.' );
+ } else {
+ $compId = (int)$gBitDb->getOne(
+ "SELECT lc.`content_id` FROM `".BIT_DB_PREFIX."liberty_content` lc
+ WHERE lc.`content_type_guid` = 'stockcomponent' AND lc.`title` = ?",
+ [ $title ]
+ );
+
+ if( !$compId ) {
+ header( 'Location: '.STOCK_PKG_URL.'edit_component.php?title='.urlencode( $title ) );
+ die;
+ }
+
+ $nextXorder = (int)$gBitDb->getOne(
+ "SELECT COALESCE( MAX(x.`xorder`) + 1, 1 ) FROM `".BIT_DB_PREFIX."liberty_xref` x
+ WHERE x.`content_id` = ? AND x.`item` IN ('SGL','PCK','SHT','VOL')",
+ [ $gContent->mContentId ]
+ ) ?: 1;
+
+ $xrefObj = new LibertyXref();
+ $xrefObj->mContentTypeGuid = 'stockmovement';
+ $pHash = [
+ 'content_id' => $gContent->mContentId,
+ 'item' => $item,
+ 'xorder' => $nextXorder,
+ 'xref' => $compId,
+ 'xkey' => $xkey,
+ 'xkey_ext' => trim( $_REQUEST['xkey_ext'] ?? '' ) ?: null,
+ 'edit' => trim( $_REQUEST['edit'] ?? '' ) ?: null,
+ ];
+ if( $xrefObj->store( $pHash ) ) {
+ header( 'Location: '.STOCK_PKG_URL.'edit_movement.php?content_id='.$gContent->mContentId );
+ die;
+ }
+ $errors[] = KernelTools::tra( 'Failed to store BOM entry.' );
+ }
+}
+
+$gBitSmarty->assign( 'validItems', $validItems );
+$gBitSmarty->assign( 'errors', $errors );
+$gBitSmarty->assign( 'lookupUrl', STOCK_PKG_URL.'includes/lookup_component.php' );
+
+$gBitSystem->display( 'bitpackage:stock/add_movement_component.tpl', KernelTools::tra( 'Add Component' ).': '.$gContent->getTitle(), [ 'display_mode' => 'edit' ] );
diff --git a/add_requisition.php b/add_requisition.php
index db9a9b7..9f8f58f 100644
--- a/add_requisition.php
+++ b/add_requisition.php
@@ -10,7 +10,7 @@ use Bitweaver\KernelTools;
require_once '../kernel/includes/setup_inc.php';
-global $gBitSystem, $gBitSmarty, $gBitUser;
+global $gBitSystem, $gBitSmarty, $gBitUser, $gBitDb;
$gBitSystem->verifyPermission( 'p_stock_create' );
@@ -20,27 +20,27 @@ if( !empty( $_REQUEST['fCancel'] ) ) {
}
if( !empty( $_REQUEST['fCreate'] ) ) {
- $assemblyContentId = isset( $_REQUEST['assembly_content_id'] ) && is_numeric( $_REQUEST['assembly_content_id'] )
- ? (int)$_REQUEST['assembly_content_id'] : null;
- $kitCount = isset( $_REQUEST['kit_count'] ) && is_numeric( $_REQUEST['kit_count'] ) && (float)$_REQUEST['kit_count'] > 0
- ? (float)$_REQUEST['kit_count'] : 1;
+ $targetContentId = isset( $_REQUEST['assembly_content_id'] ) && is_numeric( $_REQUEST['assembly_content_id'] )
+ ? (int)$_REQUEST['assembly_content_id'] : null;
+ $kitCount = isset( $_REQUEST['kit_count'] ) && is_numeric( $_REQUEST['kit_count'] ) && (float)$_REQUEST['kit_count'] > 0
+ ? (float)$_REQUEST['kit_count'] : 1;
$title = trim( $_REQUEST['title'] ?? '' );
- if( !$assemblyContentId ) {
- $errors[] = KernelTools::tra( 'Please select an assembly.' );
+ if( !$targetContentId ) {
+ $errors[] = KernelTools::tra( 'Please select an assembly or component.' );
} elseif( $title === '' ) {
$errors[] = KernelTools::tra( 'Please enter an RQ number.' );
} else {
- // Verify assembly exists without relying on content type registry
- global $gBitDb;
- $assemblyExists = $gBitDb->getOne(
- "SELECT `content_id` FROM `".BIT_DB_PREFIX."liberty_content`
- WHERE `content_id` = ? AND `content_type_guid` = 'stockassembly'",
- [ $assemblyContentId ]
+ $targetRow = $gBitDb->getRow(
+ "SELECT `content_type_guid`, `title` FROM `".BIT_DB_PREFIX."liberty_content`
+ WHERE `content_id` = ? AND `content_type_guid` IN ('stockassembly','stockcomponent')",
+ [ $targetContentId ]
);
- if( !$assemblyExists ) {
- $errors[] = KernelTools::tra( 'Assembly not found.' );
+ $targetGuid = $targetRow['content_type_guid'] ?? null;
+ $targetTitle = $targetRow['title'] ?? '';
+ if( !$targetGuid ) {
+ $errors[] = KernelTools::tra( 'Item not found.' );
} else {
$movement = new StockMovement();
$paramHash = [
@@ -54,15 +54,40 @@ if( !empty( $_REQUEST['fCreate'] ) ) {
'xkey' => $title,
'fAddXref' => 1,
];
+ $orderedDateStr = trim( $_REQUEST['ordered_date'] ?? '' );
+ if( $orderedDateStr !== '' ) {
+ $parts = explode( '/', $orderedDateStr );
+ if( count( $parts ) === 3 ) {
+ $year = (int)$parts[2] < 100 ? 2000 + (int)$parts[2] : (int)$parts[2];
+ $ts = mktime( 0, 0, 0, (int)$parts[1], (int)$parts[0], $year );
+ if( $ts ) $reqnHash['start_date'] = $ts;
+ }
+ }
$movement->storeXref( $reqnHash );
$assemblyHash = [
'content_id' => $movement->mContentId,
'item' => 'ASSEMBLY',
- 'xref' => $assemblyContentId,
+ 'xref' => $targetContentId,
+ 'xkey' => (string)$kitCount,
+ 'xkey_ext' => $targetTitle,
+ 'edit' => $targetGuid,
'fAddXref' => 1,
];
$movement->storeXref( $assemblyHash );
- $movement->explodeFromAssembly( $assemblyContentId, $kitCount );
+ if( $targetGuid === 'stockassembly' ) {
+ $movement->explodeFromAssembly( $targetContentId, $kitCount );
+ } else {
+ // Single component — one SGL quantity row
+ $qtyHash = [
+ 'content_id' => $movement->mContentId,
+ 'item' => 'SGL',
+ 'xref' => $targetContentId,
+ 'xkey' => (string)$kitCount,
+ 'xorder' => 1,
+ 'fAddXref' => 1,
+ ];
+ $movement->storeXref( $qtyHash );
+ }
header( 'Location: '.STOCK_PKG_URL.'edit_movement.php?content_id='.$movement->mContentId );
die;
}
@@ -71,10 +96,17 @@ if( !empty( $_REQUEST['fCreate'] ) ) {
}
}
-// Assembly list for selector
-$assembly = new StockAssembly();
-$listHash = [ 'show_empty' => true, 'sort_mode' => 'title_asc', 'max_records' => 500 ];
-$assemblyList = $assembly->getList( $listHash );
+// Assemblies and components merged into one flat alphabetical list
+$assembly = new StockAssembly();
+$asmHash = [ 'show_empty' => true, 'sort_mode' => 'title_asc', 'max_records' => 1000 ];
+$assemblyList = $assembly->getList( $asmHash );
+
+$component = new StockComponent();
+$compHash = [ 'kitlocker_only' => true, 'sort_mode' => 'title_asc', 'max_records' => 1000 ];
+$componentList = $component->getList( $compHash );
+
+$itemList = array_merge( array_values( $assemblyList ), array_values( $componentList ) );
+usort( $itemList, fn( $a, $b ) => strcasecmp( $a['title'], $b['title'] ) );
// Pre-select if coming from list_stock
$preselect = isset( $_REQUEST['assembly_content_id'] ) && is_numeric( $_REQUEST['assembly_content_id'] )
@@ -82,9 +114,31 @@ $preselect = isset( $_REQUEST['assembly_content_id'] ) && is_numeric( $_REQUEST[
$kitCount = isset( $_REQUEST['kit_count'] ) && is_numeric( $_REQUEST['kit_count'] )
? (float)$_REQUEST['kit_count'] : 1;
-$gBitSmarty->assign( 'assemblyList', $assemblyList );
-$gBitSmarty->assign( 'preselect', $preselect );
-$gBitSmarty->assign( 'kitCount', $kitCount );
-$gBitSmarty->assign( 'errors', $errors ?? [] );
+$itemIds = array_column( $itemList, 'content_id' );
+$klidMap = [];
+if( $itemIds ) {
+ $klidRows = $gBitDb->getAll(
+ "SELECT x.`content_id`, x.`xkey` FROM `".BIT_DB_PREFIX."liberty_xref` x
+ WHERE x.`item` = 'KLID' AND x.`content_id` IN (".implode( ',', array_fill( 0, count( $itemIds ), '?' ) ).")",
+ $itemIds
+ );
+ foreach( $klidRows as $r ) { $klidMap[$r['content_id']] = $r['xkey']; }
+}
+$itemListJson = json_encode( array_map(
+ fn( $i ) => [ 'id' => (int)$i['content_id'], 'text' => $i['title'], 'klid' => $klidMap[$i['content_id']] ?? '' ],
+ $itemList
+) );
+$preselectTitle = '';
+if( $preselect ) {
+ foreach( $itemList as $item ) {
+ if( (int)$item['content_id'] === $preselect ) { $preselectTitle = $item['title']; break; }
+ }
+}
+$gBitSmarty->assign( 'itemListJson', $itemListJson );
+$gBitSmarty->assign( 'preselect', $preselect );
+$gBitSmarty->assign( 'preselectTitle', $preselectTitle );
+$gBitSmarty->assign( 'kitCount', $kitCount );
+$gBitSmarty->assign( 'todayFormatted', date( 'd/m/Y' ) );
+$gBitSmarty->assign( 'errors', $errors ?? [] );
$gBitSystem->display( 'bitpackage:stock/add_requisition.tpl', KernelTools::tra( 'Create Requisition' ), [ 'display_mode' => 'edit' ] );
diff --git a/add_supplier.php b/add_supplier.php
index f834380..8235311 100644
--- a/add_supplier.php
+++ b/add_supplier.php
@@ -6,7 +6,6 @@
namespace Bitweaver\Stock;
-use Bitweaver\Contact\Contact;
use Bitweaver\KernelTools;
require_once '../kernel/includes/setup_inc.php';
@@ -48,11 +47,7 @@ if( !empty( $_REQUEST['fAddSupplier'] ) ) {
}
}
-$contact = new Contact();
-$listHash = [ 'contact_type_guid' => ['$04'], 'sort_mode' => 'title_asc', 'max_records' => 500 ];
-$supplierList = $contact->getList( $listHash );
-
-$gBitSmarty->assign( 'supplierList', $supplierList );
+$gBitSmarty->assign( 'contactLookupUrl', CONTACT_PKG_URL.'includes/lookup_contact.php' );
$gBitSmarty->assign( 'errors', $gContent->mErrors );
$gBitSystem->display( 'bitpackage:stock/add_supplier.tpl', KernelTools::tra( 'Add Supplier' ), [ 'display_mode' => 'edit' ] );
diff --git a/admin/schema_inc.php b/admin/schema_inc.php
index 8db4e41..0e42764 100755
--- a/admin/schema_inc.php
+++ b/admin/schema_inc.php
@@ -161,8 +161,12 @@ $xrefItems[] = "INSERT INTO `{$X}liberty_xref_item` (`item`,`content_type_guid`,
// stockmovement xref groups
$xrefTypes[] = "INSERT INTO `{$X}liberty_xref_group` (`x_group`,`content_type_guid`,`title`,`sort_order`,`role_id`,`type_href`) VALUES ('reference','stockmovement','Reference',1,3,'')";
+$xrefTypes[] = "INSERT INTO `{$X}liberty_xref_group` (`x_group`,`content_type_guid`,`title`,`sort_order`,`role_id`,`type_href`,`template`) VALUES ('assembly','stockmovement','Assembly',1,3,'','assembly')";
$xrefTypes[] = "INSERT INTO `{$X}liberty_xref_group` (`x_group`,`content_type_guid`,`title`,`sort_order`,`role_id`,`type_href`,`template`) VALUES ('quantity','stockmovement','Items',2,3,'','bom')";
+// assembly item — links the movement to its assembly or component (multiple=1 for future multi-item reqns)
+$xrefItems[] = "INSERT INTO `{$X}liberty_xref_item` (`item`,`content_type_guid`,`x_group`,`cross_ref_title`,`multiple`,`role_id`,`cross_ref_href`,`template`,`data`) VALUES ('ASSEMBLY','stockmovement','assembly','Assembly',1,3,'','assembly',NULL)";
+
// reference items — REQN=out, TRANS=in from elf, ORDER=in from supplier
$xrefItems[] = "INSERT INTO `{$X}liberty_xref_item` (`item`,`content_type_guid`,`x_group`,`cross_ref_title`,`multiple`,`role_id`,`cross_ref_href`,`template`,`data`) VALUES ('REQN', 'stockmovement','reference','Requisition',1,3,'','text',NULL)";
$xrefItems[] = "INSERT INTO `{$X}liberty_xref_item` (`item`,`content_type_guid`,`x_group`,`cross_ref_title`,`multiple`,`role_id`,`cross_ref_href`,`template`,`data`) VALUES ('TRANS','stockmovement','reference','Transfer', 1,3,'','text',NULL)";
diff --git a/edit_movement.php b/edit_movement.php
index 2f686d4..816553c 100644
--- a/edit_movement.php
+++ b/edit_movement.php
@@ -107,6 +107,51 @@ if( !empty( $_REQUEST['fSave'] ) ) {
$gBitSmarty->assign( 'csvErrors', $csvResult['errors'] );
}
+} elseif( !empty( $_REQUEST['fAddAssembly'] ) && $gContent->isValid() ) {
+ $targetContentId = isset( $_REQUEST['assembly_content_id'] ) && is_numeric( $_REQUEST['assembly_content_id'] )
+ ? (int)$_REQUEST['assembly_content_id'] : 0;
+ $kitCount = isset( $_REQUEST['kit_count'] ) && is_numeric( $_REQUEST['kit_count'] ) && (float)$_REQUEST['kit_count'] > 0
+ ? (float)$_REQUEST['kit_count'] : 1;
+ if( $targetContentId ) {
+ $targetRow = $gBitDb->getRow(
+ "SELECT `content_type_guid`, `title` FROM `".BIT_DB_PREFIX."liberty_content`
+ WHERE `content_id` = ? AND `content_type_guid` IN ('stockassembly','stockcomponent')",
+ [ $targetContentId ]
+ );
+ if( $targetRow ) {
+ $assemblyHash = [
+ 'content_id' => $gContent->mContentId,
+ 'item' => 'ASSEMBLY',
+ 'xref' => $targetContentId,
+ 'xkey' => (string)$kitCount,
+ 'xkey_ext' => $targetRow['title'],
+ 'edit' => $targetRow['content_type_guid'],
+ 'fAddXref' => 1,
+ ];
+ $gContent->storeXref( $assemblyHash );
+ if( $targetRow['content_type_guid'] === 'stockassembly' ) {
+ $gContent->explodeFromAssembly( $targetContentId, $kitCount );
+ } else {
+ $nextXorder = (int)$gBitDb->getOne(
+ "SELECT COALESCE( MAX(x.`xorder`) + 1, 1 ) FROM `".BIT_DB_PREFIX."liberty_xref` x
+ WHERE x.`content_id` = ? AND x.`item` IN ('SGL','PCK','SHT','VOL')",
+ [ $gContent->mContentId ]
+ ) ?: 1;
+ $qtyHash = [
+ 'content_id' => $gContent->mContentId,
+ 'item' => 'SGL',
+ 'xref' => $targetContentId,
+ 'xkey' => (string)$kitCount,
+ 'xorder' => $nextXorder,
+ 'fAddXref' => 1,
+ ];
+ $gContent->storeXref( $qtyHash );
+ }
+ }
+ }
+ header( 'Location: '.STOCK_PKG_URL.'edit_movement.php?content_id='.$gContent->mContentId );
+ die;
+
} elseif( !empty( $_REQUEST['delete'] ) ) {
$gBitSystem->verifyPermission( 'p_stock_admin' );
if( !empty( $_REQUEST['cancel'] ) ) {
@@ -133,10 +178,6 @@ if( $gContent->isValid() ) {
$gBitSmarty->assign( 'gXrefInfo', $gContent->mXrefInfo );
}
-// Pre-populate reference row for the form
-$refRow = !empty( $gContent->mInfo['reference'] ) ? reset( $gContent->mInfo['reference'] ) : [];
-$gBitSmarty->assign( 'refRow', $refRow );
-
// Pre-format dates as dd/mm/yyyy for form fields
$orderedDateVal = !empty( $gContent->mInfo['ref_start_date'] )
? date( 'd/m/Y', strtotime( $gContent->mInfo['ref_start_date'] ) ) : '';
@@ -146,6 +187,31 @@ $gBitSmarty->assign( 'orderedDateVal', $orderedDateVal );
$gBitSmarty->assign( 'receivedDateVal', $receivedDateVal );
$gBitSmarty->assign( 'contactLookupUrl', CONTACT_PKG_URL.'includes/lookup_contact.php' );
+$isReqn = ( ( $gContent->mInfo['ref_type'] ?? '' ) === 'REQN' );
+if( $isReqn ) {
+ $assembly = new StockAssembly();
+ $asmHash = [ 'show_empty' => true, 'sort_mode' => 'title_asc', 'max_records' => 1000 ];
+ $assemblyList = $assembly->getList( $asmHash );
+ $component = new StockComponent();
+ $compHash = [ 'kitlocker_only' => true, 'sort_mode' => 'title_asc', 'max_records' => 1000 ];
+ $componentList = $component->getList( $compHash );
+ $itemList = array_merge( array_values( $assemblyList ), array_values( $componentList ) );
+ usort( $itemList, fn( $a, $b ) => strcasecmp( $a['title'], $b['title'] ) );
+ $itemIds = array_column( $itemList, 'content_id' );
+ $klidMap = [];
+ if( $itemIds ) {
+ $klidRows = $gBitDb->getAll(
+ "SELECT x.`content_id`, x.`xkey` FROM `".BIT_DB_PREFIX."liberty_xref` x
+ WHERE x.`item` = 'KLID' AND x.`content_id` IN (".implode( ',', array_fill( 0, count( $itemIds ), '?' ) ).")",
+ $itemIds
+ );
+ foreach( $klidRows as $r ) { $klidMap[$r['content_id']] = $r['xkey']; }
+ }
+ $gBitSmarty->assign( 'itemListJson', json_encode(
+ array_map( fn( $i ) => [ 'id' => (int)$i['content_id'], 'text' => $i['title'], 'klid' => $klidMap[$i['content_id']] ?? '' ], $itemList )
+ ) );
+}
+$gBitSmarty->assign( 'isReqn', $isReqn );
$gBitSmarty->assign( 'refTypes', $refTypes );
$gBitSmarty->assign( 'errors', $gContent->mErrors );
diff --git a/import/ImportKitlockerAssemblies.php b/import/ImportKitlockerAssemblies.php
index aa96a1e..9c12200 100644
--- a/import/ImportKitlockerAssemblies.php
+++ b/import/ImportKitlockerAssemblies.php
@@ -4,19 +4,47 @@
*
* CSV column layout (0-based, header row skipped by loader):
* 0 Title MERG product code / designation (used as lc.title)
- * 1 KLID Kitlocker numeric ID code (stored as KLID xref on assemblies)
+ * 1 KLID Kitlocker numeric ID code (stored as KLID xref on both types)
* 2 Description Long description (stored as content body)
- * 3 KLSGL Kitlocker single-unit stock count (assemblies only → KLSGL xref)
- * 4 KL3M 3-month sales count (assemblies only → KL3M xref)
- * 5 Group Group number 1–28 → KLG01–KLG28 stgrp xref (both types)
+ * 3 KLSGL Kitlocker single-unit stock count (stored as KLSGL xref on both types)
+ * 4 KL3M 3-month sales count (stored as KL3M xref on both types)
+ * 5 Group Group number 1–99 → KLG01–KLG99 stgrp xref (both types)
* 6 Type 'A' = StockAssembly, 'C' = StockComponent
*
+ * On reload, existing records are not re-created but their kitlocker xrefs are upserted,
+ * so a fresh import run will tag components that were previously missing KLID etc.
+ *
* @package stock
*/
use Bitweaver\Stock\StockAssembly;
use Bitweaver\Stock\StockComponent;
+function stockKitlockerXrefUpsert( int $contentId, string $item, string $value ): void {
+ global $gBitDb;
+ $existingId = $gBitDb->getOne(
+ "SELECT `xref_id` FROM `".BIT_DB_PREFIX."liberty_xref`
+ WHERE `content_id` = ? AND `item` = ?",
+ [ $contentId, $item ]
+ );
+ if( $existingId ) {
+ $gBitDb->associateUpdate(
+ BIT_DB_PREFIX.'liberty_xref',
+ [ 'xkey' => substr( $value, 0, 32 ), 'last_update_date' => $gBitDb->NOW() ],
+ [ 'xref_id' => $existingId ]
+ );
+ } else {
+ $gBitDb->associateInsert( BIT_DB_PREFIX.'liberty_xref', [
+ 'xref_id' => $gBitDb->GenID( 'liberty_xref_seq' ),
+ 'content_id' => $contentId,
+ 'item' => $item,
+ 'xkey' => substr( $value, 0, 32 ),
+ 'xorder' => 0,
+ 'last_update_date' => $gBitDb->NOW(),
+ ] );
+ }
+}
+
function stockExpungeKitlockerItemByTitle( string $title, string $type ): bool {
global $gBitDb;
@@ -53,65 +81,43 @@ function stockImportKitlockerItem( array $data, int $rowNum ): array {
return $result;
}
- $guid = ( $type === 'A' ) ? 'stockassembly' : 'stockcomponent';
-
- $exists = $gBitDb->getOne(
- "SELECT lc.`content_id` FROM `".BIT_DB_PREFIX."liberty_content` lc
- WHERE lc.`content_type_guid` = ? AND lc.`title` = ?",
- [ $guid, $title ]
- );
- if( $exists ) {
- $result['skipped']++;
- $result['errors'][] = "Row $rowNum: '$title' already exists, skipped.";
- return $result;
- }
-
+ $guid = ( $type === 'A' ) ? 'stockassembly' : 'stockcomponent';
$klid = trim( $data[1] ?? '' );
$desc = trim( $data[2] ?? '' );
$klsgl = trim( $data[3] ?? '' );
$kl3m = trim( $data[4] ?? '' );
$group = (int)( $data[5] ?? 0 );
- $obj = ( $type === 'A' ) ? new StockAssembly() : new StockComponent();
- $pHash = [
- 'title' => $title,
- 'edit' => $desc,
- 'format_guid' => 'bithtml',
- ];
- if( !$obj->store( $pHash ) ) {
- $result['skipped']++;
- $result['errors'][] = "Row $rowNum: failed to store '$title'.";
- return $result;
- }
+ $contentId = (int)$gBitDb->getOne(
+ "SELECT lc.`content_id` FROM `".BIT_DB_PREFIX."liberty_content` lc
+ WHERE lc.`content_type_guid` = ? AND lc.`title` = ?",
+ [ $guid, $title ]
+ );
- $contentId = $obj->mContentId;
+ if( !$contentId ) {
+ $obj = ( $type === 'A' ) ? new StockAssembly() : new StockComponent();
+ $pHash = [
+ 'title' => $title,
+ 'edit' => $desc,
+ 'format_guid' => 'bithtml',
+ ];
+ if( !$obj->store( $pHash ) ) {
+ $result['skipped']++;
+ $result['errors'][] = "Row $rowNum: failed to store '$title'.";
+ return $result;
+ }
+ $contentId = $obj->mContentId;
+ }
- // Group tag — shared across both types via 'stock' package-level stgrp
+ // Group tag — both types
if( $group >= 1 && $group <= 99 ) {
- $xrefId = $gBitDb->GenID( 'liberty_xref_seq' );
- $gBitDb->associateInsert( BIT_DB_PREFIX.'liberty_xref', [
- 'xref_id' => $xrefId,
- 'content_id' => $contentId,
- 'item' => sprintf( 'KLG%02d', $group ),
- 'xorder' => 0,
- 'last_update_date' => $gBitDb->NOW(),
- ] );
+ stockKitlockerXrefUpsert( $contentId, sprintf( 'KLG%02d', $group ), '' );
}
- // Assembly-specific kitlocker xrefs
- if( $type === 'A' ) {
- foreach( [ 'KLID' => $klid, 'KLSGL' => $klsgl, 'KL3M' => $kl3m ] as $item => $value ) {
- if( $value !== '' ) {
- $xrefId = $gBitDb->GenID( 'liberty_xref_seq' );
- $gBitDb->associateInsert( BIT_DB_PREFIX.'liberty_xref', [
- 'xref_id' => $xrefId,
- 'content_id' => $contentId,
- 'item' => $item,
- 'xorder' => 0,
- 'xkey' => substr( $value, 0, 32 ),
- 'last_update_date' => $gBitDb->NOW(),
- ] );
- }
+ // Kitlocker xrefs — same set for assemblies and components
+ foreach( [ 'KLID' => $klid, 'KLSGL' => $klsgl, 'KL3M' => $kl3m ] as $item => $value ) {
+ if( $value !== '' ) {
+ stockKitlockerXrefUpsert( $contentId, $item, $value );
}
}
diff --git a/includes/classes/StockComponent.php b/includes/classes/StockComponent.php
index 6a67318..37d3659 100755
--- a/includes/classes/StockComponent.php
+++ b/includes/classes/StockComponent.php
@@ -189,6 +189,10 @@ class StockComponent extends StockBase {
$bindVars[] = $term;
}
+ if( !empty( $pListHash['kitlocker_only'] ) ) {
+ $whereSql .= " AND EXISTS (SELECT 1 FROM `".BIT_DB_PREFIX."liberty_xref` kx WHERE kx.`content_id` = lc.`content_id` AND kx.`item` = 'KLID')";
+ }
+
$this->getServicesSql( 'content_list_sql_function', $selectSql, $joinSql, $whereSql, $bindVars );
$orderby = !empty( $pListHash['sort_mode'] )
diff --git a/includes/classes/StockMovement.php b/includes/classes/StockMovement.php
index 543c588..d731050 100644
--- a/includes/classes/StockMovement.php
+++ b/includes/classes/StockMovement.php
@@ -194,6 +194,23 @@ class StockMovement extends LibertyContent {
}
}
unset( $row );
+
+ $assemblyGroup = $this->mXrefInfo->mGroups['assembly'] ?? null;
+ if( !$assemblyGroup || empty( $assemblyGroup->mXrefs ) ) return;
+ $assemblyIds = array_values( array_unique( array_filter( array_column( $assemblyGroup->mXrefs, 'xref' ) ) ) );
+ if( !$assemblyIds ) return;
+ $assemblies = $this->mDb->getAssoc(
+ "SELECT lc.`content_id`, lc.`title`, lc.`data` FROM `".BIT_DB_PREFIX."liberty_content` lc
+ WHERE lc.`content_id` IN (".implode( ',', array_fill( 0, count( $assemblyIds ), '?' ) ).")",
+ $assemblyIds
+ );
+ foreach( $assemblyGroup->mXrefs as &$row ) {
+ if( !empty( $row['xref'] ) && isset( $assemblies[$row['xref']] ) ) {
+ $row['linked_title'] = $assemblies[$row['xref']]['title'];
+ $row['linked_desc'] = $assemblies[$row['xref']]['data'];
+ }
+ }
+ unset( $row );
}
/**
diff --git a/list_stock.php b/list_stock.php
index 1a8e7c7..141b187 100644
--- a/list_stock.php
+++ b/list_stock.php
@@ -134,8 +134,24 @@ if( $assemblyContentId && isset( $assemblyList[$assemblyContentId] ) ) {
$assemblyTitle = $assemblyList[$assemblyContentId]['title'];
}
+// KLID map for assembly autocomplete
+$asmIds = array_keys( $assemblyList );
+$asmKlidMap = [];
+if( $asmIds ) {
+ $klidRows = $gBitDb->getAll(
+ "SELECT x.`content_id`, x.`xkey` FROM `".BIT_DB_PREFIX."liberty_xref` x
+ WHERE x.`item` = 'KLID' AND x.`content_id` IN (".implode( ',', array_fill( 0, count( $asmIds ), '?' ) ).")",
+ $asmIds
+ );
+ foreach( $klidRows as $r ) { $asmKlidMap[$r['content_id']] = $r['xkey']; }
+}
+$assemblyListJson = json_encode( array_values( array_map(
+ fn( $i ) => [ 'id' => (int)$i['content_id'], 'text' => $i['title'], 'klid' => $asmKlidMap[$i['content_id']] ?? '' ],
+ $assemblyList
+) ) );
+
$gBitSmarty->assign( 'stockList', $stockList );
-$gBitSmarty->assign( 'assemblyList', $assemblyList );
+$gBitSmarty->assign( 'assemblyListJson', $assemblyListJson );
$gBitSmarty->assign( 'assemblyContentId', $assemblyContentId );
$gBitSmarty->assign( 'assemblyTitle', $assemblyTitle );
$gBitSmarty->assign( 'find', $find );
diff --git a/templates/add_movement_component.tpl b/templates/add_movement_component.tpl
new file mode 100644
index 0000000..7eb1175
--- /dev/null
+++ b/templates/add_movement_component.tpl
@@ -0,0 +1,113 @@
+{strip}
+<div class="edit stock">
+ <div class="header">
+ <h1>{tr}Add Component{/tr}: {$gContent->getTitle()|escape}</h1>
+ </div>
+
+ <div class="body">
+ {formfeedback error=$errors}
+
+ {form id="addComponentForm" ipackage="stock" ifile="add_movement_component.php"}
+ <input type="hidden" name="content_id" value="{$gContent->mContentId}" />
+
+ <div class="form-group">
+ {formlabel label="Component" for="component_title" mandatory="y"}
+ {forminput}
+ <input type="hidden" id="component_id_unused" />
+ <div style="position:relative">
+ <input type="text" class="form-control" name="component_title" id="component_title"
+ autocomplete="off"
+ value="{$smarty.request.component_title|default:''|escape}"
+ placeholder="{tr}Type to search…{/tr}" />
+ <ul id="comp_dropdown" class="dropdown-menu"
+ style="display:none;position:absolute;width:100%;z-index:1000;max-height:220px;overflow-y:auto"></ul>
+ </div>
+ {formhelp note="Type to search existing components, or enter a new title to create one."}
+ {/forminput}
+ </div>
+
+ <div class="form-group">
+ {formlabel label="Qty Type" for="item"}
+ {forminput}
+ <select name="item" id="item" class="form-control">
+ {foreach from=$validItems key=code item=label}
+ <option value="{$code}"{if $smarty.request.item|default:'SGL' eq $code} selected="selected"{/if}>{$label|escape}</option>
+ {/foreach}
+ </select>
+ {/forminput}
+ </div>
+
+ <div class="form-group">
+ {formlabel label="Qty" for="xkey"}
+ {forminput}
+ <input type="text" class="form-control" name="xkey" id="xkey"
+ value="{$smarty.request.xkey|default:''|escape}" />
+ {/forminput}
+ </div>
+
+ <div class="form-group">
+ {formlabel label="Ref designators" for="xkey_ext"}
+ {forminput}
+ <input type="text" class="form-control" name="xkey_ext" id="xkey_ext"
+ value="{$smarty.request.xkey_ext|default:''|escape}" />
+ {/forminput}
+ </div>
+
+ <div class="form-group">
+ {formlabel label="Note" for="edit"}
+ {forminput}
+ <input type="text" class="form-control" name="edit" id="edit"
+ value="{$smarty.request.edit|default:''|escape}" />
+ {/forminput}
+ </div>
+
+ <div class="form-group submit">
+ <input type="submit" class="btn btn-default" name="fCancel" value="{tr}Cancel{/tr}" />
+ <input type="submit" class="btn btn-primary" name="fAddComponent" value="{tr}Add Component{/tr}" />
+ </div>
+ {/form}
+ </div>
+</div>
+{/strip}
+<script>
+(function($) {
+ var timer;
+ var $input = $('#component_title');
+ var $dd = $('#comp_dropdown');
+
+ $input.on('input', function() {
+ var q = $(this).val();
+ clearTimeout(timer);
+ $dd.hide().empty();
+ if (q.length < 2) return;
+ timer = setTimeout(function() {
+ $.getJSON('{$lookupUrl}', {ldelim}q: q{rdelim}, function(data) {
+ if (!data.length) return;
+ $.each(data, function(i, row) {
+ $dd.append($('<li>').append(
+ $('<a>').attr('href','#').data('title', row.title).text(row.title)
+ ));
+ });
+ $dd.show();
+ });
+ }, 250);
+ });
+
+ $(document).on('mousedown', '#comp_dropdown a', function(e) {
+ e.preventDefault();
+ $input.val($(this).data('title'));
+ $dd.hide().empty();
+ });
+
+ $input.on('blur', function() { setTimeout(function() { $dd.hide(); }, 150); });
+
+ $input.on('keydown', function(e) {
+ if (!$dd.is(':visible')) return;
+ var $links = $dd.find('a'), idx = $links.index($dd.find('li.active a'));
+ if (e.key === 'ArrowDown') { e.preventDefault(); $links.parent().removeClass('active'); $links.eq(idx + 1 < $links.length ? idx + 1 : 0).parent().addClass('active'); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); $links.parent().removeClass('active'); $links.eq(idx > 0 ? idx - 1 : $links.length - 1).parent().addClass('active'); }
+ else if (e.key === 'Enter') { var $a = $dd.find('li.active a'); if ($a.length) { e.preventDefault(); $a.trigger('mousedown'); } }
+ else if (e.key === 'Escape') { $dd.hide(); }
+ });
+}(jQuery));
+</script>
diff --git a/templates/add_requisition.tpl b/templates/add_requisition.tpl
index 58796e1..3b6051b 100644
--- a/templates/add_requisition.tpl
+++ b/templates/add_requisition.tpl
@@ -18,27 +18,36 @@
</div>
<div class="form-group">
- {formlabel label="Assembly" for="assembly_content_id" mandatory="y"}
+ {formlabel label="Ordered" for="ordered_date"}
{forminput}
- <select name="assembly_content_id" id="assembly_content_id" class="form-control">
- <option value="">{tr}— Select assembly —{/tr}</option>
- {foreach from=$assemblyList item=asm}
- <option value="{$asm.content_id|escape}"
- {if $preselect == $asm.content_id} selected="selected"{/if}>
- {$asm.title|escape}
- </option>
- {/foreach}
- </select>
+ <input type="text" class="form-control input-small" name="ordered_date" id="ordered_date"
+ placeholder="dd/mm/yyyy" maxlength="10"
+ value="{$smarty.request.ordered_date|default:$todayFormatted|escape}" />
{/forminput}
</div>
<div class="form-group">
- {formlabel label="Kits" for="kit_count"}
+ {formlabel label="Item" for="assembly_search" mandatory="y"}
+ {forminput}
+ <input type="hidden" name="assembly_content_id" id="assembly_content_id"
+ value="{$preselect|default:''|escape}" />
+ <div style="position:relative">
+ <input type="text" class="form-control" id="assembly_search"
+ autocomplete="off"
+ value="{$preselectTitle|escape}"
+ placeholder="Type to search…" />
+ <ul id="assembly_dropdown" class="dropdown-menu"
+ style="display:none;position:absolute;width:100%;z-index:1000;max-height:220px;overflow-y:auto"></ul>
+ </div>
+ {/forminput}
+ </div>
+
+ <div class="form-group">
+ {formlabel label="Qty" for="kit_count"}
{forminput}
<input type="number" class="form-control input-sm" name="kit_count"
id="kit_count" min="1" step="1" style="width:6em"
value="{$kitCount|escape}" />
- {formhelp note="Number of assemblies to requisition"}
{/forminput}
</div>
@@ -51,3 +60,60 @@
</div>
</div>
{/strip}
+<script>
+(function($) {
+ var items = {$itemListJson};
+ var $input = $('#assembly_search');
+ var $hidden = $('#assembly_content_id');
+ var $dd = $('#assembly_dropdown');
+
+ $input.on('input', function() {
+ var q = this.value.toLowerCase().trim();
+ $dd.hide().empty();
+ $hidden.val('');
+ if (!q) return;
+ var matched = items.filter(function(i) {
+ return i.text.toLowerCase().indexOf(q) !== -1 || (i.klid && i.klid.toLowerCase().indexOf(q) !== -1);
+ });
+ if (!matched.length) return;
+ matched.forEach(function(i) {
+ var label = i.text + (i.klid ? ' (' + i.klid + ')' : '');
+ $dd.append($('<li>').append(
+ $('<a>').attr('href', '#').data('id', i.id).data('text', i.text).text(label)
+ ));
+ });
+ $dd.show();
+ });
+
+ $(document).on('mousedown', '#assembly_dropdown a', function(e) {
+ e.preventDefault();
+ $input.val($(this).data('text'));
+ $hidden.val($(this).data('id'));
+ $dd.hide().empty();
+ });
+
+ $input.on('blur', function() {
+ setTimeout(function() { $dd.hide(); }, 150);
+ });
+
+ $input.on('keydown', function(e) {
+ if (!$dd.is(':visible')) return;
+ var $links = $dd.find('a');
+ var idx = $links.index($dd.find('li.active a'));
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ $links.parent().removeClass('active');
+ $links.eq(idx + 1 < $links.length ? idx + 1 : 0).parent().addClass('active');
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ $links.parent().removeClass('active');
+ $links.eq(idx > 0 ? idx - 1 : $links.length - 1).parent().addClass('active');
+ } else if (e.key === 'Enter') {
+ var $active = $dd.find('li.active a');
+ if ($active.length) { e.preventDefault(); $active.trigger('mousedown'); }
+ } else if (e.key === 'Escape') {
+ $dd.hide();
+ }
+ });
+}(jQuery));
+</script>
diff --git a/templates/add_supplier.tpl b/templates/add_supplier.tpl
index 72e0195..adcfd52 100644
--- a/templates/add_supplier.tpl
+++ b/templates/add_supplier.tpl
@@ -11,14 +11,15 @@
<input type="hidden" name="content_id" value="{$gContent->mContentId}" />
<div class="form-group">
- {formlabel label="Supplier" for="supplier_content_id"}
+ {formlabel label="Supplier" for="sup_search"}
{forminput}
- <select name="supplier_content_id" id="supplier_content_id" class="form-control">
- <option value="">{tr}-- Select supplier --{/tr}</option>
- {foreach from=$supplierList item=sup}
- <option value="{$sup.content_id|escape}">{$sup.title|escape}</option>
- {/foreach}
- </select>
+ <input type="hidden" name="supplier_content_id" id="supplier_content_id" value="" />
+ <div style="position:relative">
+ <input type="text" class="form-control" id="sup_search"
+ autocomplete="off" placeholder="{tr}Type to search contacts…{/tr}" />
+ <ul id="sup_dropdown" class="dropdown-menu"
+ style="display:none;position:absolute;width:100%;z-index:1000;max-height:220px;overflow-y:auto"></ul>
+ </div>
{/forminput}
</div>
@@ -48,6 +49,54 @@
<input type="submit" class="btn btn-primary" name="fAddSupplier" value="{tr}Save{/tr}" />
</div>
{/form}
- </div><!-- end .body -->
-</div><!-- end .stock -->
+ </div>
+</div>
{/strip}
+<script>
+(function($) {
+ var timer, contacts = [];
+ var $input = $('#sup_search');
+ var $hidden = $('#supplier_content_id');
+ var $dd = $('#sup_dropdown');
+
+ $input.on('input', function() {
+ var q = $(this).val();
+ clearTimeout(timer);
+ $dd.hide().empty();
+ contacts = [];
+ if (q.length < 2) return;
+ timer = setTimeout(function() {
+ $.getJSON('{$contactLookupUrl}', {ldelim}q: q{rdelim}, function(data) {
+ contacts = data;
+ if (!data.length) return;
+ $.each(data, function(i, row) {
+ var label = row.title + (row.scref ? ' (' + row.scref + ')' : '');
+ $dd.append($('<li>').append(
+ $('<a>').attr('href','#').data('id', row.content_id).data('label', label).text(label)
+ ));
+ });
+ $dd.show();
+ });
+ }, 250);
+ });
+
+ $(document).on('mousedown', '#sup_dropdown a', function(e) {
+ e.preventDefault();
+ $input.val($(this).data('label'));
+ $hidden.val($(this).data('id'));
+ $dd.hide().empty();
+ contacts = [];
+ });
+
+ $input.on('blur', function() { setTimeout(function() { $dd.hide(); }, 150); });
+
+ $input.on('keydown', function(e) {
+ if (!$dd.is(':visible')) return;
+ var $links = $dd.find('a'), idx = $links.index($dd.find('li.active a'));
+ if (e.key === 'ArrowDown') { e.preventDefault(); $links.parent().removeClass('active'); $links.eq(idx + 1 < $links.length ? idx + 1 : 0).parent().addClass('active'); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); $links.parent().removeClass('active'); $links.eq(idx > 0 ? idx - 1 : $links.length - 1).parent().addClass('active'); }
+ else if (e.key === 'Enter') { var $a = $dd.find('li.active a'); if ($a.length) { e.preventDefault(); $a.trigger('mousedown'); } }
+ else if (e.key === 'Escape') { $dd.hide(); }
+ });
+}(jQuery));
+</script>
diff --git a/templates/edit_movement.tpl b/templates/edit_movement.tpl
index baec74b..c4081dc 100644
--- a/templates/edit_movement.tpl
+++ b/templates/edit_movement.tpl
@@ -24,6 +24,7 @@
{/forminput}
</div>
+ {if !$isReqn}
{if $refTypes}
<div class="form-group">
{formlabel label="Movement Type" mandatory="y"}
@@ -31,8 +32,7 @@
{foreach from=$refTypes key=item item=label}
<label class="radio-inline">
<input type="radio" name="movement_type" value="{$item|escape}"
- {if $refRow.item eq $item} checked="checked"
- {elseif !$refRow && $smarty.foreach.default.first} checked="checked"{/if} /> {$label|escape}
+ {if ($gContent->mInfo.ref_type|default:'TRANS') eq $item} checked="checked"{/if} /> {$label|escape}
</label>
{/foreach}
{/forminput}
@@ -44,11 +44,14 @@
{forminput}
<input type="hidden" name="ref_contact_id" id="ref_contact_id"
value="{$gContent->mInfo.ref_contact_id|default:''|escape}" />
- <input type="text" class="form-control" name="ref_from" id="ref_from"
- autocomplete="off" list="contact_suggestions"
- value="{if $gContent->mInfo.ref_contact_name}{$gContent->mInfo.ref_contact_name|escape}{else}{$refRow.data|default:''|escape}{/if}"
- maxlength="160" placeholder="Type to search contacts…" />
- <datalist id="contact_suggestions"></datalist>
+ <div style="position:relative">
+ <input type="text" class="form-control" name="ref_from" id="ref_from"
+ autocomplete="off"
+ value="{if $gContent->mInfo.ref_contact_name}{$gContent->mInfo.ref_contact_name|escape}{else}{$gContent->mInfo.ref_from_data|default:''|escape}{/if}"
+ maxlength="160" placeholder="Type to search contacts…" />
+ <ul id="contact_dropdown" class="dropdown-menu"
+ style="display:none;position:absolute;width:100%;z-index:1000;max-height:220px;overflow-y:auto"></ul>
+ </div>
{/forminput}
</div>
@@ -56,9 +59,10 @@
{formlabel label="Ref Key" for="ref_key"}
{forminput}
<input type="text" class="form-control" name="ref_key" id="ref_key"
- value="{$refRow.xkey|default:''|escape}" maxlength="160" />
+ value="{$gContent->mInfo.ref_key|default:''|escape}" maxlength="160" />
{/forminput}
</div>
+ {/if}
<div class="form-group">
{formlabel label="Ordered" for="ordered_date"}
@@ -103,7 +107,21 @@
{if $gContent->isValid()}
- {* ── Upload CSV ── *}
+ {if $gXrefInfo->mGroups}
+ {jstabs}
+ {foreach $gXrefInfo->mGroups as $xrefGroup}
+ {if $xrefGroup->mXGroup neq 'reference' && ($xrefGroup->mXGroup neq 'assembly' || $isReqn)}
+ {include file=$gContent->getXrefListTemplate($xrefGroup->mTemplate)
+ xrefGroup=$xrefGroup
+ allow_add=true
+ allow_edit=true}
+ {/if}
+ {/foreach}
+ {/jstabs}
+ {/if}
+
+ {if !$isReqn}
+ {* ── Upload CSV (orders/transfers only) ── *}
<h4>{tr}Upload CSV{/tr}</h4>
{form enctype="multipart/form-data" ipackage="stock" ifile="edit_movement.php"}
<input type="hidden" name="content_id" value="{$gContent->mContentId|escape}" />
@@ -112,6 +130,7 @@
<input type="submit" class="btn btn-default" name="upload_csv" value="{tr}Upload{/tr}" />
</div>
{/form}
+ {/if}
{* ── Upload results ── *}
{if isset($csvLoaded)}
@@ -126,20 +145,6 @@
{/if}
{/if}
- {* ── Xref tabs — quantity BOM only; reference handled above in form ── *}
- {if $gXrefInfo->mGroups}
- {jstabs}
- {foreach $gXrefInfo->mGroups as $xrefGroup}
- {if $xrefGroup->mXGroup neq 'reference'}
- {include file=$gContent->getXrefListTemplate($xrefGroup->mTemplate)
- xrefGroup=$xrefGroup
- allow_add=true
- allow_edit=true}
- {/if}
- {/foreach}
- {/jstabs}
- {/if}
-
{/if}
</div><!-- end .body -->
@@ -147,27 +152,61 @@
<script>
(function($) {
var timer, contacts = [];
- $('#ref_from').on('input', function() {
+ var $input = $('#ref_from');
+ var $hidden = $('#ref_contact_id');
+ var $dd = $('#contact_dropdown');
+
+ $input.on('input', function() {
var q = $(this).val();
clearTimeout(timer);
- if (q.length < 2) { $('#contact_suggestions').empty(); contacts = []; return; }
+ $dd.hide().empty();
+ contacts = [];
+ if (q.length < 2) return;
timer = setTimeout(function() {
$.getJSON('{$contactLookupUrl}', {ldelim}q: q{rdelim}, function(data) {
contacts = data;
- var dl = $('#contact_suggestions').empty();
+ if (!data.length) return;
$.each(data, function(i, row) {
var label = row.title + (row.scref ? ' (' + row.scref + ')' : '');
- dl.append($('<option>').val(label).attr('data-id', row.content_id));
+ $dd.append($('<li>').append(
+ $('<a>').attr('href','#').data('id', row.content_id).data('label', label).text(label)
+ ));
});
+ $dd.show();
});
}, 250);
- }).on('change', function() {
- var val = $(this).val(), found = null;
- $.each(contacts, function(i, row) {
- var label = row.title + (row.scref ? ' (' + row.scref + ')' : '');
- if (label === val || row.title === val || row.scref === val) { found = row; return false; }
- });
- $('#ref_contact_id').val(found ? found.content_id : '');
+ });
+
+ $(document).on('mousedown', '#contact_dropdown a', function(e) {
+ e.preventDefault();
+ $input.val($(this).data('label'));
+ $hidden.val($(this).data('id'));
+ $dd.hide().empty();
+ contacts = [];
+ });
+
+ $input.on('blur', function() {
+ setTimeout(function() { $dd.hide(); }, 150);
+ });
+
+ $input.on('keydown', function(e) {
+ if (!$dd.is(':visible')) return;
+ var $items = $dd.find('a');
+ var idx = $dd.find('li.active a').length ? $items.index($dd.find('li.active a')) : -1;
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ $items.parent().removeClass('active');
+ $items.eq(idx + 1 < $items.length ? idx + 1 : 0).parent().addClass('active');
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ $items.parent().removeClass('active');
+ $items.eq(idx > 0 ? idx - 1 : $items.length - 1).parent().addClass('active');
+ } else if (e.key === 'Enter') {
+ var $active = $dd.find('li.active a');
+ if ($active.length) { e.preventDefault(); $active.trigger('mousedown'); }
+ } else if (e.key === 'Escape') {
+ $dd.hide();
+ }
});
}(jQuery));
</script>
diff --git a/templates/list_movements.tpl b/templates/list_movements.tpl
index 220eca1..aef033b 100644
--- a/templates/list_movements.tpl
+++ b/templates/list_movements.tpl
@@ -3,6 +3,7 @@
<header>
<div class="floaticon">
{if $gBitUser->hasPermission('p_stock_create')}
+ <a href="{$smarty.const.STOCK_PKG_URL}add_requisition.php">{biticon ipackage="icons" iname="list-add" iexplain="Add Requisition"}</a>
<a href="{$smarty.const.STOCK_PKG_URL}edit_movement.php">{biticon ipackage="icons" iname="view-task-add" iexplain="Add Movement"}</a>
{/if}
</div>
diff --git a/templates/list_stock.tpl b/templates/list_stock.tpl
index efbe222..edb6afe 100644
--- a/templates/list_stock.tpl
+++ b/templates/list_stock.tpl
@@ -12,15 +12,14 @@
{form ipackage="stock" ifile="list_stock.php" method="get"}
<div class="form-inline" style="margin-bottom:1em">
<div class="form-group">
- <select name="assembly_content_id" class="form-control input-sm">
- <option value="">{tr}All components{/tr}</option>
- {foreach from=$assemblyList item=asm}
- <option value="{$asm.content_id|escape}"
- {if $assemblyContentId == $asm.content_id} selected="selected"{/if}>
- {$asm.title|escape}
- </option>
- {/foreach}
- </select>
+ <input type="hidden" name="assembly_content_id" id="ls_asm_id" value="{$assemblyContentId|default:''|escape}" />
+ <div style="position:relative;display:inline-block;vertical-align:top">
+ <input type="text" class="form-control input-sm" id="ls_asm_search"
+ autocomplete="off" placeholder="{tr}All components{/tr}"
+ value="{$assemblyTitle|escape}" />
+ <ul id="ls_asm_dropdown" class="dropdown-menu"
+ style="display:none;position:absolute;width:100%;z-index:1000;max-height:220px;overflow-y:auto"></ul>
+ </div>
</div>
<div class="form-group">
<input type="text" class="form-control input-sm" name="find"
@@ -99,3 +98,45 @@
</section>
</div>
{/strip}
+<script>
+(function($) {
+ var items = {$assemblyListJson};
+ var $input = $('#ls_asm_search');
+ var $hidden = $('#ls_asm_id');
+ var $dd = $('#ls_asm_dropdown');
+
+ $input.on('input', function() {
+ var q = this.value.toLowerCase().trim();
+ $hidden.val('');
+ $dd.hide().empty();
+ if (!q) return;
+ var matched = items.filter(function(i) {
+ return i.text.toLowerCase().indexOf(q) !== -1 || (i.klid && i.klid.toLowerCase().indexOf(q) !== -1);
+ });
+ if (!matched.length) return;
+ matched.forEach(function(i) {
+ var label = i.text + (i.klid ? ' (' + i.klid + ')' : '');
+ $dd.append($('<li>').append($('<a>').attr('href','#').data('id', i.id).data('text', i.text).text(label)));
+ });
+ $dd.show();
+ });
+
+ $(document).on('mousedown', '#ls_asm_dropdown a', function(e) {
+ e.preventDefault();
+ $input.val($(this).data('text'));
+ $hidden.val($(this).data('id'));
+ $dd.hide().empty();
+ });
+
+ $input.on('blur', function() { setTimeout(function() { $dd.hide(); }, 150); });
+
+ $input.on('keydown', function(e) {
+ if (!$dd.is(':visible')) return;
+ var $links = $dd.find('a'), idx = $links.index($dd.find('li.active a'));
+ if (e.key === 'ArrowDown') { e.preventDefault(); $links.parent().removeClass('active'); $links.eq(idx + 1 < $links.length ? idx + 1 : 0).parent().addClass('active'); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); $links.parent().removeClass('active'); $links.eq(idx > 0 ? idx - 1 : $links.length - 1).parent().addClass('active'); }
+ else if (e.key === 'Enter') { var $a = $dd.find('li.active a'); if ($a.length) { e.preventDefault(); $a.trigger('mousedown'); } }
+ else if (e.key === 'Escape') { $dd.hide(); }
+ });
+}(jQuery));
+</script>
diff --git a/templates/stockmovement/view_xref_bom_group.tpl b/templates/stockmovement/view_xref_bom_group.tpl
index d253e92..0787905 100644
--- a/templates/stockmovement/view_xref_bom_group.tpl
+++ b/templates/stockmovement/view_xref_bom_group.tpl
@@ -28,7 +28,7 @@
</div>
{if $allow_add && $gContent->isValid() && $gContent->hasUpdatePermission() && !$isHistory}
<div>
- {smartlink ititle="Add record" ipackage="liberty" ifile="add_xref.php" biticon="list-add" content_id=$gContent->mInfo.content_id group=$xrefGroup->mSortOrder}
+ {smartlink ititle="Add component" ipackage="stock" ifile="add_movement_component.php" biticon="list-add" content_id=$gContent->mInfo.content_id}
</div>
{/if}
{/legend}
diff --git a/templates/view_movement.tpl b/templates/view_movement.tpl
index f46ca6c..f3d157f 100644
--- a/templates/view_movement.tpl
+++ b/templates/view_movement.tpl
@@ -45,7 +45,7 @@
{if $gXrefInfo->mGroups}
{jstabs}
{foreach $gXrefInfo->mGroups as $xrefGroup}
- {if $xrefGroup->mXGroup neq 'reference'}
+ {if $xrefGroup->mXGroup neq 'reference' && ($xrefGroup->mXGroup neq 'assembly' || $isReqn)}
{include file=$gContent->getXrefListTemplate($xrefGroup->mTemplate)
xrefGroup=$xrefGroup
allow_add=false
diff --git a/templates/view_xref_assembly_group.tpl b/templates/view_xref_assembly_group.tpl
new file mode 100644
index 0000000..ab77013
--- /dev/null
+++ b/templates/view_xref_assembly_group.tpl
@@ -0,0 +1,113 @@
+{assign var=xrefAllowEdit value=$allow_edit|default:false}
+{assign var=xrefAllowAdd value=$allow_add|default:false}
+{assign var=isHistory value=($xrefGroup->mXGroup eq 'history')}
+{jstab title="{tr}Assembly{/tr} ({$xrefGroup->mXrefs|@count})"}
+{legend legend="Assembly"}
+<div class="form-group table-responsive">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>{tr}Item{/tr}</th>
+ <th>{tr}Type{/tr}</th>
+ <th>{tr}Qty{/tr}</th>
+ {if $xrefAllowEdit}<th></th>{/if}
+ </tr>
+ </thead>
+ <tbody>
+ {if $xrefGroup->mXrefs}
+ {foreach $xrefGroup->mXrefs as $xrefInfo}
+ <tr class="{cycle values='even,odd'}">
+ {include file=$gContent->getXrefRecordTemplate($xrefInfo.template)}
+ </tr>
+ {/foreach}
+ {else}
+ <tr class="norecords">
+ <td colspan="{if $xrefAllowEdit}4{else}3{/if}">{tr}No items{/tr}</td>
+ </tr>
+ {/if}
+ </tbody>
+ </table>
+</div>
+
+{if $xrefAllowAdd && $itemListJson}
+<hr />
+<h5>{tr}Add item{/tr}</h5>
+{form ipackage="stock" ifile="edit_movement.php"}
+ <input type="hidden" name="content_id" value="{$gContent->mContentId|escape}" />
+ <div class="form-inline">
+ <input type="hidden" name="assembly_content_id" id="asm_add_id" value="" />
+ <div style="position:relative;display:inline-block;width:20em;vertical-align:top">
+ <input type="text" class="form-control" id="asm_add_search"
+ autocomplete="off" placeholder="{tr}Type to search…{/tr}" />
+ <ul id="asm_add_dropdown" class="dropdown-menu"
+ style="display:none;position:absolute;width:100%;z-index:1000;max-height:220px;overflow-y:auto"></ul>
+ </div>
+ &nbsp;
+ <input type="number" class="form-control input-sm" name="kit_count"
+ min="1" step="1" value="1" style="width:5em;display:inline-block" title="{tr}Qty / kits{/tr}" />
+ &nbsp;
+ <input type="submit" class="btn btn-default btn-sm" name="fAddAssembly" value="{tr}Add{/tr}" />
+ </div>
+{/form}
+{/if}
+{/legend}
+{/jstab}
+{if $xrefAllowAdd && $itemListJson}
+<script>
+(function($) {
+ var items = {$itemListJson};
+ var $input = $('#asm_add_search');
+ var $hidden = $('#asm_add_id');
+ var $dd = $('#asm_add_dropdown');
+
+ $input.on('input', function() {
+ var q = this.value.toLowerCase().trim();
+ $dd.hide().empty();
+ $hidden.val('');
+ if (!q) return;
+ var matched = items.filter(function(i) {
+ return i.text.toLowerCase().indexOf(q) !== -1 || (i.klid && i.klid.toLowerCase().indexOf(q) !== -1);
+ });
+ if (!matched.length) return;
+ matched.forEach(function(i) {
+ var label = i.text + (i.klid ? ' (' + i.klid + ')' : '');
+ $dd.append($('<li>').append(
+ $('<a>').attr('href','#').data('id', i.id).data('text', i.text).text(label)
+ ));
+ });
+ $dd.show();
+ });
+
+ $(document).on('mousedown', '#asm_add_dropdown a', function(e) {
+ e.preventDefault();
+ $input.val($(this).data('text'));
+ $hidden.val($(this).data('id'));
+ $dd.hide().empty();
+ });
+
+ $input.on('blur', function() {
+ setTimeout(function() { $dd.hide(); }, 150);
+ });
+
+ $input.on('keydown', function(e) {
+ if (!$dd.is(':visible')) return;
+ var $links = $dd.find('a');
+ var idx = $links.index($dd.find('li.active a'));
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ $links.parent().removeClass('active');
+ $links.eq(idx + 1 < $links.length ? idx + 1 : 0).parent().addClass('active');
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ $links.parent().removeClass('active');
+ $links.eq(idx > 0 ? idx - 1 : $links.length - 1).parent().addClass('active');
+ } else if (e.key === 'Enter') {
+ var $active = $dd.find('li.active a');
+ if ($active.length) { e.preventDefault(); $active.trigger('mousedown'); }
+ } else if (e.key === 'Escape') {
+ $dd.hide();
+ }
+ });
+}(jQuery));
+</script>
+{/if}
diff --git a/templates/view_xref_assembly_item.tpl b/templates/view_xref_assembly_item.tpl
new file mode 100644
index 0000000..e1572d6
--- /dev/null
+++ b/templates/view_xref_assembly_item.tpl
@@ -0,0 +1,24 @@
+{strip}
+<td>
+ {assign var=asmTitle value=$xrefInfo.linked_title|default:$xrefInfo.xkey_ext}
+ {if $xrefInfo.xref}
+ <a href="{if $xrefInfo.data eq 'stockassembly'}{$smarty.const.STOCK_PKG_URL}view_assembly.php?content_id={$xrefInfo.xref}{else}{$smarty.const.STOCK_PKG_URL}view_component.php?content_id={$xrefInfo.xref}{/if}">{$asmTitle|escape}</a>
+ {else}
+ {$asmTitle|escape}
+ {/if}
+ {if $xrefInfo.linked_desc}
+ <div class="small text-muted">{$xrefInfo.linked_desc|strip_tags|truncate:120|escape}</div>
+ {/if}
+</td>
+<td>{if $xrefInfo.data eq 'stockassembly'}{tr}Assembly{/tr}{else}{tr}Component{/tr}{/if}</td>
+<td>{$xrefInfo.xkey|escape}</td>
+{if $xrefAllowEdit|default:false}
+<td>
+ <span class="actionicon">
+ {if $gContent->hasExpungePermission()}
+ {smartlink ititle="Remove" ipackage="liberty" ifile="edit_xref.php" biticon="user-trash" content_id=$gContent->mInfo.content_id xref_id=$xrefInfo.xref_id expunge=1}
+ {/if}
+ </span>
+</td>
+{/if}
+{/strip}
diff --git a/view_movement.php b/view_movement.php
index 43327ae..395fce0 100644
--- a/view_movement.php
+++ b/view_movement.php
@@ -23,5 +23,6 @@ $gBitSystem->setCanonicalLink( $gContent->getDisplayUrl() );
$gContent->loadXrefInfo();
$gBitSmarty->assign( 'gXrefInfo', $gContent->mXrefInfo );
+$gBitSmarty->assign( 'isReqn', ( ( $gContent->mInfo['ref_type'] ?? '' ) === 'REQN' ) );
$gBitSystem->display( 'bitpackage:stock/view_movement.tpl', $gContent->getTitle() );