feat(medical-store): inward purchase medicine auto-search and shared pharmacy master integration

This commit is contained in:
DevSoftTech Server 2026-09-10 04:23:09 +05:30
parent 101e0631a8
commit 62397c5a98
4 changed files with 1093 additions and 37 deletions

View File

@ -1663,6 +1663,9 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct
$routes->get('medical-store/patient/search', 'MedicalStoreApi::searchPatient');
$routes->get('medical-store/patient/prescription/(:segment)/(:num)', 'MedicalStoreApi::getPrescription/$1/$2');
$routes->get('medical-store/items/search', 'MedicalStoreApi::searchItems');
$routes->get('medical-store/items/search-master', 'MedicalStoreApi::searchMasterItems');
$routes->get('medical-store/items/master-support-data', 'MedicalStoreApi::getMasterSupportData');
$routes->post('medical-store/items/save-master', 'MedicalStoreApi::saveProductMaster');
$routes->post('medical-store/stock/opening', 'MedicalStoreApi::saveOpeningStock');
$routes->get('medical-store/stock/list', 'MedicalStoreApi::stockList');
$routes->get('medical-store/stock/alerts', 'MedicalStoreApi::stockAlerts');

View File

@ -350,6 +350,393 @@ class MedicalStoreApi extends BaseController
return $this->response->setJSON(['status' => 1, 'items' => $enriched]);
}
/**
* Searches both med_product_master and mst_items for purchase inward autocomplete.
*/
public function searchMasterItems()
{
$q = trim($this->request->getGet('q') ?? '');
$results = [];
$seenNames = [];
// 1. Query mst_items
$builder = $this->db->table('mst_items i');
$builder->select('i.item_id, i.product_master_id, i.item_name, i.generic_name, i.category, i.hsn_code, i.gst_rate, i.unit_pack, i.units_per_pack, i.drug_schedule, i.manufacturer_name');
$builder->where('i.is_active', 1);
if (!empty($q)) {
$builder->groupStart()
->like('i.item_name', $q)
->orLike('i.generic_name', $q)
->groupEnd();
}
$mstItems = $builder->limit(40)->get()->getResultArray();
foreach ($mstItems as $it) {
$nameKey = strtolower(trim($it['item_name']));
$seenNames[$nameKey] = true;
$results[] = [
'item_id' => (int)$it['item_id'],
'product_master_id' => !empty($it['product_master_id']) ? (int)$it['product_master_id'] : null,
'item_name' => $it['item_name'],
'generic_name' => $it['generic_name'] ?? '',
'category' => $it['category'] ?? 'Tablet',
'hsn_code' => $it['hsn_code'] ?? '3004',
'gst_rate' => (float)($it['gst_rate'] ?? 12.00),
'unit_pack' => $it['unit_pack'] ?? '',
'units_per_pack' => (int)($it['units_per_pack'] ?? 10),
'drug_schedule' => $it['drug_schedule'] ?? 'OTC',
'manufacturer_name' => $it['manufacturer_name'] ?? '',
'source' => 'mst_items'
];
}
// 2. Query legacy/shared med_product_master
if ($this->db->tableExists('med_product_master')) {
$pmBuilder = $this->db->table('med_product_master p');
$pmBuilder->select('p.id, p.item_name, p.formulation, p.genericname, p.packing, p.HSNCODE, p.CGST_per, p.SGST_per, p.company_name, p.mfgname, p.schedule_h, p.schedule_h1, p.schedule_x, p.narcotic');
$pmBuilder->where('p.is_continue', 1);
if (!empty($q)) {
$pmBuilder->groupStart()
->like('p.item_name', $q)
->orLike('p.genericname', $q)
->groupEnd();
}
$pmItems = $pmBuilder->limit(40)->get()->getResultArray();
foreach ($pmItems as $pm) {
$nameKey = strtolower(trim($pm['item_name']));
if (isset($seenNames[$nameKey])) {
continue;
}
$seenNames[$nameKey] = true;
$packNum = (int)preg_replace('/[^0-9]/', '', (string)($pm['packing'] ?? ''));
if ($packNum <= 0) $packNum = 10;
$gst = (float)($pm['CGST_per'] ?? 0) + (float)($pm['SGST_per'] ?? 0);
if ($gst <= 0) $gst = 12.00;
$schedule = 'OTC';
if (!empty($pm['schedule_h1'])) $schedule = 'Schedule H1';
else if (!empty($pm['schedule_h'])) $schedule = 'Schedule H';
else if (!empty($pm['schedule_x'])) $schedule = 'Schedule X';
else if (!empty($pm['narcotic'])) $schedule = 'Narcotic';
$company = !empty($pm['company_name']) && $pm['company_name'] !== '0' ? $pm['company_name'] : (!empty($pm['mfgname']) && $pm['mfgname'] !== '0' ? $pm['mfgname'] : '');
$formulation = !empty($pm['formulation']) && $pm['formulation'] !== '0' ? trim($pm['formulation']) : 'Tablet';
// Ensure it exists in mst_items with linked product_master_id
$existingMst = $this->db->table('mst_items')
->where('product_master_id', (int)$pm['id'])
->orWhere('LOWER(TRIM(item_name))', $nameKey)
->get()
->getRowArray();
if ($existingMst) {
$itemId = (int)$existingMst['item_id'];
if (empty($existingMst['product_master_id'])) {
$this->db->table('mst_items')->where('item_id', $itemId)->update(['product_master_id' => (int)$pm['id']]);
}
} else {
$newMstData = [
'product_master_id' => (int)$pm['id'],
'item_name' => trim($pm['item_name']),
'generic_name' => trim($pm['genericname'] ?? ''),
'category' => $formulation,
'hsn_code' => !empty($pm['HSNCODE']) && $pm['HSNCODE'] !== '0' ? trim($pm['HSNCODE']) : '3004',
'gst_rate' => $gst,
'unit_pack' => $packNum . ' ' . $formulation,
'units_per_pack' => $packNum,
'drug_schedule' => $schedule,
'manufacturer_name' => $company,
'is_active' => 1,
'min_reorder_level' => 10
];
$this->db->table('mst_items')->insert($newMstData);
$itemId = (int)$this->db->insertID();
}
$results[] = [
'item_id' => $itemId,
'product_master_id' => (int)$pm['id'],
'item_name' => trim($pm['item_name']),
'generic_name' => trim($pm['genericname'] ?? ''),
'category' => $formulation,
'hsn_code' => !empty($pm['HSNCODE']) && $pm['HSNCODE'] !== '0' ? trim($pm['HSNCODE']) : '3004',
'gst_rate' => $gst,
'unit_pack' => $packNum . ' ' . $formulation,
'units_per_pack' => $packNum,
'drug_schedule' => $schedule,
'manufacturer_name' => $company,
'source' => 'med_product_master'
];
}
}
return $this->response->setJSON([
'status' => 1,
'items' => $results
]);
}
/**
* Returns support table data (formulations, companies, GST rates) for adding medicines to master.
*/
public function getMasterSupportData()
{
$formulations = [];
if ($this->db->tableExists('med_formulation')) {
$rows = $this->db->table('med_formulation')
->select('formulation, formulation_length')
->where('formulation !=', '')
->where('formulation !=', '0')
->orderBy('formulation', 'ASC')
->limit(100)
->get()
->getResultArray();
foreach ($rows as $r) {
$f = trim($r['formulation']);
if ($f !== '' && !in_array($f, $formulations, true)) {
$formulations[] = $f;
}
}
}
$defaults = ['Tablet', 'Capsule', 'Syrup', 'Injection', 'Ointment', 'Cream', 'Gel', 'Drops', 'Suspension', 'Inhaler', 'IV Fluid', 'Powder', 'Lotion', 'Mouthwash', 'Spray', 'Soap'];
foreach ($defaults as $d) {
if (!in_array($d, $formulations, true)) {
array_unshift($formulations, $d);
}
}
$companies = [];
if ($this->db->tableExists('med_company')) {
$cRows = $this->db->table('med_company')
->select('id, company_name')
->where('company_name !=', '')
->where('company_name !=', '0')
->orderBy('company_name', 'ASC')
->limit(200)
->get()
->getResultArray();
foreach ($cRows as $cr) {
$cn = trim($cr['company_name']);
if ($cn !== '' && !in_array($cn, $companies, true)) {
$companies[] = $cn;
}
}
}
$gstRates = [0, 5, 12, 18, 28];
if ($this->db->tableExists('med_gst_per')) {
$gRows = $this->db->table('med_gst_per')->select('gst_per')->orderBy('gst_per', 'ASC')->get()->getResultArray();
if (!empty($gRows)) {
$customRates = [];
foreach ($gRows as $gr) {
$r = (float)$gr['gst_per'];
if (!in_array($r, $customRates, true)) {
$customRates[] = $r;
}
}
if (!empty($customRates)) {
$gstRates = $customRates;
}
}
}
$schedules = ['OTC', 'Schedule H', 'Schedule H1', 'Schedule X', 'Schedule G', 'Narcotic'];
return $this->response->setJSON([
'status' => 1,
'formulations' => array_values($formulations),
'companies' => array_values($companies),
'gst_rates' => $gstRates,
'schedules' => $schedules
]);
}
/**
* Saves a new or updated medicine into the shared med_product_master and mirrors into mst_items.
*/
public function saveProductMaster()
{
$json = $this->request->getJSON(true) ?: $this->request->getPost();
$itemName = trim($json['item_name'] ?? '');
if ($itemName === '') {
return $this->response->setStatusCode(400)->setJSON([
'status' => 0,
'message' => 'Medicine Name is required.'
]);
}
$genericName = trim($json['generic_name'] ?? '');
$formulation = trim($json['formulation'] ?? $json['category'] ?? 'Tablet');
if ($formulation === '') $formulation = 'Tablet';
$unitsPerPack = max(1, (int)($json['units_per_pack'] ?? 10));
$packing = trim($json['packing'] ?? (string)$unitsPerPack);
$hsnCode = trim($json['hsn_code'] ?? '3004');
$gstRate = (float)($json['gst_rate'] ?? 12.00);
$drugSchedule = trim($json['drug_schedule'] ?? 'OTC');
$companyName = trim($json['company_name'] ?? $json['manufacturer_name'] ?? '');
$minReorder = max(1, (int)($json['min_reorder_level'] ?? 10));
$this->db->transStart();
// 1. Sync support table med_formulation
$formulationId = 0;
if ($this->db->tableExists('med_formulation') && $formulation !== '') {
$existingForm = $this->db->table('med_formulation')
->where('LOWER(TRIM(formulation))', strtolower($formulation))
->get()
->getRowArray();
if ($existingForm) {
$formulationId = (int)$existingForm['id'];
} else {
$this->db->table('med_formulation')->insert([
'formulation' => $formulation,
'formulation_length' => $formulation
]);
$formulationId = (int)$this->db->insertID();
}
}
// 2. Sync support table med_company
$companyId = 0;
if ($this->db->tableExists('med_company') && $companyName !== '') {
$existingComp = $this->db->table('med_company')
->where('LOWER(TRIM(company_name))', strtolower($companyName))
->get()
->getRowArray();
if ($existingComp) {
$companyId = (int)$existingComp['id'];
} else {
$this->db->table('med_company')->insert([
'company_name' => $companyName
]);
$companyId = (int)$this->db->insertID();
}
}
// 3. Save into med_product_master
$productMasterId = 0;
$halfGst = round($gstRate / 2, 2);
if ($this->db->tableExists('med_product_master')) {
$pFields = $this->db->getFieldNames('med_product_master') ?? [];
$pmRow = $this->db->table('med_product_master')
->where('LOWER(TRIM(item_name))', strtolower($itemName))
->get()
->getRowArray();
$pmData = [
'item_name' => $itemName,
'formulation' => $formulation,
'formulation_id' => $formulationId,
'genericname' => $genericName,
'packing' => $packing,
'unit_1' => '0',
'unit_2' => '0',
'HSNCODE' => $hsnCode,
'CGST_per' => $halfGst,
'SGST_per' => $halfGst,
'company_name' => $companyName,
'company_id' => $companyId,
'mfgname' => $companyName,
're_order_qty' => $minReorder,
'is_continue' => 1,
'batch_applicable' => 1,
'exp_date_applicable' => 1,
'schedule_h' => (stripos($drugSchedule, 'Schedule H1') === false && stripos($drugSchedule, 'Schedule H') !== false) ? 1 : 0,
'schedule_h1' => stripos($drugSchedule, 'Schedule H1') !== false ? 1 : 0,
'schedule_x' => stripos($drugSchedule, 'Schedule X') !== false ? 1 : 0,
'schedule_g' => stripos($drugSchedule, 'Schedule G') !== false ? 1 : 0,
'narcotic' => stripos($drugSchedule, 'Narcotic') !== false ? 1 : 0,
'update_by' => 'MedicalStore'
];
$filteredPm = [];
foreach ($pmData as $k => $v) {
if (in_array($k, $pFields, true)) {
$filteredPm[$k] = $v;
}
}
if ($pmRow) {
$productMasterId = (int)$pmRow['id'];
$this->db->table('med_product_master')->where('id', $productMasterId)->update($filteredPm);
} else {
if (in_array('insert_by', $pFields, true)) {
$filteredPm['insert_by'] = 'MedicalStore';
}
$this->db->table('med_product_master')->insert($filteredPm);
$productMasterId = (int)$this->db->insertID();
}
}
// 4. Save/mirror into mst_items
$mstRow = $this->db->table('mst_items')
->where('LOWER(TRIM(item_name))', strtolower($itemName))
->get()
->getRowArray();
$mstData = [
'product_master_id' => $productMasterId > 0 ? $productMasterId : null,
'item_name' => $itemName,
'generic_name' => $genericName,
'category' => $formulation,
'hsn_code' => $hsnCode,
'gst_rate' => $gstRate,
'unit_pack' => $unitsPerPack . ' ' . $formulation,
'units_per_pack' => $unitsPerPack,
'drug_schedule' => $drugSchedule,
'manufacturer_name' => $companyName,
'min_reorder_level' => $minReorder,
'is_active' => 1,
'updated_at' => date('Y-m-d H:i:s')
];
if ($mstRow) {
$itemId = (int)$mstRow['item_id'];
$this->db->table('mst_items')->where('item_id', $itemId)->update($mstData);
} else {
$mstData['created_at'] = date('Y-m-d H:i:s');
$this->db->table('mst_items')->insert($mstData);
$itemId = (int)$this->db->insertID();
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->response->setStatusCode(500)->setJSON([
'status' => 0,
'message' => 'Database transaction failed while saving medicine to master.'
]);
}
return $this->response->setJSON([
'status' => 1,
'message' => 'Medicine "' . $itemName . '" added to Master successfully!',
'item' => [
'item_id' => $itemId,
'product_master_id' => $productMasterId,
'item_name' => $itemName,
'generic_name' => $genericName,
'category' => $formulation,
'units_per_pack' => $unitsPerPack,
'packing' => $packing,
'hsn_code' => $hsnCode,
'gst_rate' => $gstRate,
'drug_schedule' => $drugSchedule,
'manufacturer_name' => $companyName
]
]);
}
public function saveOpeningStock()
{
$json = $this->request->getJSON(true) ?: $this->request->getPost();
@ -1682,7 +2069,61 @@ class MedicalStoreApi extends BaseController
$purchaseItems = [];
foreach ($items as $it) {
$itemId = (int)$it['item_id'];
$itemId = (int)($it['item_id'] ?? 0);
$itemName = trim($it['item_name'] ?? '');
if ($itemId <= 0 && $itemName !== '') {
$mst = $this->db->table('mst_items')
->where('LOWER(TRIM(item_name))', strtolower($itemName))
->get()
->getRowArray();
if ($mst) {
$itemId = (int)$mst['item_id'];
} else {
$uPack = (int)($it['units_per_pack'] ?? 10);
$gRate = (float)($it['gst_rate'] ?? 12.00);
$hCode = trim($it['hsn_code'] ?? '3004');
$pmId = 0;
if ($this->db->tableExists('med_product_master')) {
$pmRow = $this->db->table('med_product_master')
->where('LOWER(TRIM(item_name))', strtolower($itemName))
->get()
->getRowArray();
if ($pmRow) {
$pmId = (int)$pmRow['id'];
} else {
$this->db->table('med_product_master')->insert([
'item_name' => $itemName,
'formulation' => 'Tablet',
'genericname' => '',
'packing' => (string)$uPack,
'HSNCODE' => $hCode,
'CGST_per' => round($gRate / 2, 2),
'SGST_per' => round($gRate / 2, 2),
'is_continue' => 1,
'insert_by' => 'Purchase Inward Auto'
]);
$pmId = (int)$this->db->insertID();
}
}
$this->db->table('mst_items')->insert([
'product_master_id' => $pmId > 0 ? $pmId : null,
'item_name' => $itemName,
'category' => 'Tablet',
'hsn_code' => $hCode,
'gst_rate' => $gRate,
'units_per_pack' => $uPack,
'unit_pack' => $uPack . ' Units',
'drug_schedule' => 'OTC',
'is_active' => 1,
'min_reorder_level' => 10
]);
$itemId = (int)$this->db->insertID();
}
}
$batchNo = trim($it['batch_no']);
$expiryDate = trim($it['expiry_date']);
$qtyPacks = (int)$it['qty_packs'];

View File

@ -0,0 +1,128 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class LinkMedicalStoreToPharmacyProductMaster extends Migration
{
public function up()
{
$db = \Config\Database::connect();
// 1. Add product_master_id to mst_items if it doesn't exist
if ($db->tableExists('mst_items')) {
$fields = $db->getFieldNames('mst_items') ?? [];
if (!in_array('product_master_id', $fields, true)) {
$this->forge->addColumn('mst_items', [
'product_master_id' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
'default' => null,
'after' => 'item_id'
]
]);
// Add index
$db->query("ALTER TABLE `mst_items` ADD INDEX `idx_mst_items_product_master_id` (`product_master_id`)");
}
}
// 2. Link existing mst_items to med_product_master where item_name matches
if ($db->tableExists('mst_items') && $db->tableExists('med_product_master')) {
$db->query("
UPDATE `mst_items` i
JOIN `med_product_master` p ON LOWER(TRIM(i.item_name)) = LOWER(TRIM(p.item_name))
SET i.product_master_id = p.id
WHERE i.product_master_id IS NULL OR i.product_master_id = 0
");
// 3. For any mst_items not yet in med_product_master, sync them into med_product_master
// so HMSPharmacy immediately has all MedicalStore medicines!
$unlinkedItems = $db->table('mst_items')
->where('product_master_id IS NULL', null, false)
->orWhere('product_master_id', 0)
->get()
->getResultArray();
$pFields = $db->getFieldNames('med_product_master') ?? [];
foreach ($unlinkedItems as $item) {
$itemName = trim($item['item_name']);
if ($itemName === '') continue;
// Check if already in med_product_master
$existing = $db->table('med_product_master')
->where('LOWER(TRIM(item_name))', strtolower($itemName))
->get()
->getRowArray();
if ($existing) {
$db->table('mst_items')
->where('item_id', $item['item_id'])
->update(['product_master_id' => $existing['id']]);
continue;
}
$gst = (float)($item['gst_rate'] ?? 12.00);
$halfGst = round($gst / 2, 2);
$insertData = [
'item_name' => $itemName,
'formulation' => !empty($item['category']) ? trim($item['category']) : 'Tablet',
'formulation_id' => 0,
'genericname' => !empty($item['generic_name']) ? trim($item['generic_name']) : '',
'packing' => !empty($item['units_per_pack']) ? (string)$item['units_per_pack'] : '10',
'unit_1' => '0',
'unit_2' => '0',
'HSNCODE' => !empty($item['hsn_code']) ? trim($item['hsn_code']) : '3004',
'CGST_per' => $halfGst,
'SGST_per' => $halfGst,
'company_name' => !empty($item['manufacturer_name']) ? trim($item['manufacturer_name']) : '',
'company_id' => 0,
'mfgname' => !empty($item['manufacturer_name']) ? trim($item['manufacturer_name']) : '',
're_order_qty' => !empty($item['min_reorder_level']) ? (int)$item['min_reorder_level'] : 10,
'is_continue' => 1,
'batch_applicable' => 1,
'exp_date_applicable' => 1,
'schedule_h' => (stripos($item['drug_schedule'] ?? '', 'Schedule H1') === false && stripos($item['drug_schedule'] ?? '', 'Schedule H') !== false) ? 1 : 0,
'schedule_h1' => stripos($item['drug_schedule'] ?? '', 'Schedule H1') !== false ? 1 : 0,
'schedule_x' => stripos($item['drug_schedule'] ?? '', 'Schedule X') !== false ? 1 : 0,
'insert_by' => 'MedicalStore Migration'
];
// Filter only valid columns present in med_product_master
$filtered = [];
foreach ($insertData as $k => $v) {
if (in_array($k, $pFields, true)) {
$filtered[$k] = $v;
}
}
try {
$db->table('med_product_master')->insert($filtered);
$newMasterId = (int)$db->insertID();
if ($newMasterId > 0) {
$db->table('mst_items')
->where('item_id', $item['item_id'])
->update(['product_master_id' => $newMasterId]);
}
} catch (\Throwable $e) {
log_message('error', 'Migration med_product_master insert error: ' . $e->getMessage());
}
}
}
}
public function down()
{
$db = \Config\Database::connect();
if ($db->tableExists('mst_items')) {
$fields = $db->getFieldNames('mst_items') ?? [];
if (in_array('product_master_id', $fields, true)) {
$this->forge->dropColumn('mst_items', 'product_master_id');
}
}
}
}

View File

@ -4041,14 +4041,49 @@ function PurchaseView({ store, notify }) {
bank_details: ''
});
// Master Medicine Autocomplete & Add Modal State
const [activeSearchRow, setActiveSearchRow] = useState(null);
const [suggestions, setSuggestions] = useState([]);
const [isSearchingMeds, setIsSearchingMeds] = useState(false);
const [showMasterModal, setShowMasterModal] = useState(false);
const [masterTargetRow, setMasterTargetRow] = useState(null);
const [supportData, setSupportData] = useState({ formulations: [], companies: [], gst_rates: [0, 5, 12, 18, 28], schedules: [] });
const [isSavingMaster, setIsSavingMaster] = useState(false);
const [masterForm, setMasterForm] = useState({
item_name: '',
generic_name: '',
formulation: 'Tablet',
units_per_pack: 10,
packing: '10',
hsn_code: '3004',
gst_rate: 12,
drug_schedule: 'Schedule H',
company_name: '',
min_reorder_level: 10
});
const searchTimerRef = useRef(null);
const [rows, setRows] = useState([
{ item_name: '', generic_name: '', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12 }
{ item_id: 0, item_name: '', generic_name: '', category: 'Tablet', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12, hsn_code: '3004' }
]);
useEffect(() => {
fetchSuppliers();
fetchMasterSupportData();
}, []);
const fetchMasterSupportData = async () => {
try {
const res = await fetch(API_BASE + 'items/master-support-data');
const data = await res.json();
if (data.status) {
setSupportData(data);
}
} catch (e) {
console.error('Error fetching master support data:', e);
}
};
const fetchSuppliers = async () => {
try {
const res = await fetch(API_BASE + 'suppliers');
@ -4161,7 +4196,7 @@ function PurchaseView({ store, notify }) {
});
const addRow = () => {
setRows([...rows, { item_name: '', generic_name: '', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12 }]);
setRows([...rows, { item_id: 0, item_name: '', generic_name: '', category: 'Tablet', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12, hsn_code: '3004' }]);
};
const updateRow = (idx, field, val) => {
@ -4171,9 +4206,137 @@ function PurchaseView({ store, notify }) {
};
const removeRow = (idx) => {
if (rows.length <= 1) {
setRows([{ item_id: 0, item_name: '', generic_name: '', category: 'Tablet', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12, hsn_code: '3004' }]);
return;
}
setRows(rows.filter((_, i) => i !== idx));
};
// Autocomplete searching for Medicine Name
const handleMedicineInputChange = (rowIndex, value) => {
updateRow(rowIndex, 'item_name', value);
setActiveSearchRow(rowIndex);
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (!value.trim() || value.trim().length < 1) {
setSuggestions([]);
return;
}
searchTimerRef.current = setTimeout(async () => {
setIsSearchingMeds(true);
try {
const res = await fetch(API_BASE + 'items/search-master?q=' + encodeURIComponent(value.trim()));
const data = await res.json();
if (data.status) {
setSuggestions(data.items || []);
}
} catch (err) {
console.error('Master search error:', err);
} finally {
setIsSearchingMeds(false);
}
}, 200);
};
const handleSelectMedicine = (rowIndex, item) => {
const updated = [...rows];
updated[rowIndex] = {
...updated[rowIndex],
item_id: item.item_id || 0,
item_name: item.item_name,
generic_name: item.generic_name || '',
category: item.category || 'Tablet',
units_per_pack: Number(item.units_per_pack) > 0 ? Number(item.units_per_pack) : 10,
gst_rate: item.gst_rate !== undefined ? Number(item.gst_rate) : 12,
hsn_code: item.hsn_code || '3004'
};
setRows(updated);
setActiveSearchRow(null);
setSuggestions([]);
// Focus Batch No field for this row
setTimeout(() => {
const batchInput = document.getElementById(`batch-input-${rowIndex}`);
if (batchInput) {
batchInput.focus();
batchInput.select();
}
}, 50);
};
const openAddMasterModal = (rowIndex = null, initialName = '') => {
const prefillName = initialName || (rowIndex !== null && rows[rowIndex] ? rows[rowIndex].item_name : '');
setMasterTargetRow(rowIndex);
setMasterForm({
item_name: prefillName,
generic_name: '',
formulation: 'Tablet',
units_per_pack: 10,
packing: '10',
hsn_code: '3004',
gst_rate: 12,
drug_schedule: 'Schedule H',
company_name: '',
min_reorder_level: 10
});
setActiveSearchRow(null);
setSuggestions([]);
setShowMasterModal(true);
};
const handleSaveMasterMedicine = async (e) => {
if (e) e.preventDefault();
if (!masterForm.item_name.trim()) {
notify('Medicine name is required.', 'error');
return;
}
setIsSavingMaster(true);
try {
const res = await fetch(API_BASE + 'items/save-master', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(masterForm)
});
const data = await res.json();
if (data.status && data.item) {
notify(`"${data.item.item_name}" saved to Pharmacy Master & selected!`, 'success');
setShowMasterModal(false);
// Auto-select into the target row or create a new row
if (masterTargetRow !== null && masterTargetRow < rows.length) {
handleSelectMedicine(masterTargetRow, data.item);
} else {
setRows([...rows, {
item_id: data.item.item_id || 0,
item_name: data.item.item_name,
generic_name: data.item.generic_name || '',
category: data.item.category || 'Tablet',
batch_no: '',
expiry_date: '',
qty_packs: 1,
free_qty_packs: 0,
units_per_pack: Number(data.item.units_per_pack) || 10,
mrp: 0,
ptr: 0,
discount_pct: 0,
gst_rate: Number(data.item.gst_rate) || 12,
hsn_code: data.item.hsn_code || '3004'
}]);
}
} else {
notify(data.message || 'Failed to save medicine to master.', 'error');
}
} catch (err) {
notify('Error saving to master: ' + err.message, 'error');
} finally {
setIsSavingMaster(false);
}
};
const savePurchaseBill = async () => {
if (!selectedSupplierId || !invoiceNo) {
notify('Please select supplier and enter invoice number.', 'error');
@ -4187,27 +4350,22 @@ function PurchaseView({ store, notify }) {
}
try {
const itemsPayload = [];
for (const r of validItems) {
const searchRes = await fetch(API_BASE + `items/search?store_id=${store.store_id}&q=${encodeURIComponent(r.item_name)}`);
const sData = await searchRes.json();
let itemId = 1;
if (sData.status && sData.items && sData.items.length > 0) {
itemId = sData.items[0].item_id;
}
itemsPayload.push({
item_id: itemId,
batch_no: r.batch_no,
expiry_date: r.expiry_date,
qty_packs: Number(r.qty_packs),
free_qty_packs: Number(r.free_qty_packs || 0),
units_per_pack: Number(r.units_per_pack || 10),
mrp: Number(r.mrp),
ptr: Number(r.ptr),
discount_pct: Number(r.discount_pct || 0),
gst_rate: Number(r.gst_rate || 12)
});
}
const itemsPayload = validItems.map(r => ({
item_id: Number(r.item_id || 0),
item_name: r.item_name,
generic_name: r.generic_name || '',
category: r.category || 'Tablet',
batch_no: r.batch_no,
expiry_date: r.expiry_date,
qty_packs: Number(r.qty_packs),
free_qty_packs: Number(r.free_qty_packs || 0),
units_per_pack: Number(r.units_per_pack || 10),
mrp: Number(r.mrp),
ptr: Number(r.ptr),
discount_pct: Number(r.discount_pct || 0),
gst_rate: Number(r.gst_rate || 12),
hsn_code: r.hsn_code || '3004'
}));
const res = await fetch(API_BASE + 'purchase/save', {
method: 'POST',
@ -4224,7 +4382,7 @@ function PurchaseView({ store, notify }) {
if (data.status) {
notify('Purchase inward recorded! Supplier Ledger & Stock updated.', 'success');
setInvoiceNo('');
setRows([{ item_name: '', generic_name: '', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12 }]);
setRows([{ item_id: 0, item_name: '', generic_name: '', category: 'Tablet', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12, hsn_code: '3004' }]);
} else {
notify(data.message || 'Failed to save purchase bill.', 'error');
}
@ -4277,33 +4435,178 @@ function PurchaseView({ store, notify }) {
</div>
</div>
<div className="table-responsive">
<div className="table-responsive" style={{ overflow: 'visible' }}>
<table className="table table-pos table-bordered">
<thead>
<tr>
<th>Medicine Name</th>
<th>Batch No</th>
<th>Expiry</th>
<th style={{ width: 70 }}>Packs</th>
<th style={{ minWidth: 280 }}>
<div className="d-flex justify-content-between align-items-center">
<span>Medicine Name (Master Auto-Search)</span>
<button
type="button"
className="btn btn-link btn-sm p-0 text-decoration-none fw-semibold"
style={{ fontSize: '11px' }}
onClick={() => openAddMasterModal(rows.length - 1, '')}
>
<i className="bi bi-plus-circle me-1"></i>+ Add to Master
</button>
</div>
</th>
<th style={{ width: 130 }}>Batch No</th>
<th style={{ width: 140 }}>Expiry</th>
<th style={{ width: 75 }}>Packs</th>
<th style={{ width: 65 }}>Free</th>
<th style={{ width: 80 }}>Units/Pack</th>
<th style={{ width: 85 }}>Units/Pack</th>
<th style={{ width: 85 }}>PTR (₹)</th>
<th style={{ width: 85 }}>MRP (₹)</th>
<th style={{ width: 65 }}>GST%</th>
<th style={{ width: 75 }}>GST%</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>
<input type="text" className="form-control form-control-sm" placeholder="e.g. Augmentin 625" value={r.item_name} onChange={(e) => updateRow(i, 'item_name', e.target.value)} />
<td style={{ position: 'relative' }}>
<div className="input-group input-group-sm">
<input
type="text"
id={`med-search-input-${i}`}
className={`form-control form-control-sm fw-semibold ${r.item_id ? 'border-success' : ''}`}
placeholder="Type medicine name (e.g. Augmentin)..."
value={r.item_name}
autoComplete="off"
onChange={(e) => handleMedicineInputChange(i, e.target.value)}
onFocus={(e) => {
setActiveSearchRow(i);
if (e.target.value.trim().length >= 1) {
handleMedicineInputChange(i, e.target.value);
}
}}
/>
{r.item_id > 0 && (
<span className="input-group-text bg-success text-white py-0 px-1.5" title="Item matched in Master Catalog">
<i className="bi bi-check2"></i>
</span>
)}
</div>
{/* Subtext with Generic and Category */}
{r.generic_name ? (
<div className="text-muted text-truncate mt-1 d-flex align-items-center gap-1" style={{ fontSize: '11px', maxWidth: '270px' }}>
<span className="badge bg-light text-primary border" style={{ fontSize: '9.5px' }}>{r.category || 'Tablet'}</span>
<span>{r.generic_name}</span>
</div>
) : null}
{/* Autocomplete Dropdown */}
{activeSearchRow === i && (
<div
className="shadow-lg border rounded-2 bg-white"
style={{
position: 'absolute',
top: '100%',
left: 0,
width: '380px',
maxHeight: '280px',
overflowY: 'auto',
zIndex: 1060,
marginTop: '2px'
}}
>
{/* Dropdown Header */}
<div className="bg-light px-2.5 py-1.5 border-bottom d-flex justify-content-between align-items-center">
<span className="small fw-bold text-secondary" style={{ fontSize: '11px' }}>
{isSearchingMeds ? (
<span><i className="bi bi-hourglass-split spinner-border spinner-border-sm me-1"></i>Searching master...</span>
) : (
<span><i className="bi bi-database-check me-1 text-primary"></i>Pharmacy Master ({suggestions.length} found)</span>
)}
</span>
<button
type="button"
className="btn-close"
style={{ fontSize: '9px' }}
onClick={() => setActiveSearchRow(null)}
></button>
</div>
{/* Suggestions List */}
{suggestions.length > 0 ? (
suggestions.map((item, sIdx) => (
<div
key={sIdx}
className="p-2 border-bottom hover-bg-light"
style={{ cursor: 'pointer', transition: 'background-color 0.15s' }}
onMouseDown={(e) => {
e.preventDefault();
handleSelectMedicine(i, item);
}}
>
<div className="d-flex justify-content-between align-items-center">
<span className="fw-bold text-dark" style={{ fontSize: '12.5px' }}>{item.item_name}</span>
<span className="badge bg-primary-subtle text-primary border" style={{ fontSize: '10px' }}>
{item.category || 'Tablet'}
</span>
</div>
{item.generic_name && (
<div className="text-muted text-truncate small" style={{ fontSize: '11px' }}>
{item.generic_name}
</div>
)}
<div className="d-flex gap-2 mt-1 align-items-center" style={{ fontSize: '10.5px' }}>
<span className="text-secondary">Pack: <strong>{item.units_per_pack || 10}s</strong></span>
<span className="text-secondary">GST: <strong>{item.gst_rate}%</strong></span>
<span className="text-secondary">HSN: <strong>{item.hsn_code || '3004'}</strong></span>
{item.drug_schedule && item.drug_schedule !== 'OTC' && (
<span className="badge bg-danger-subtle text-danger" style={{ fontSize: '9px' }}>{item.drug_schedule}</span>
)}
</div>
</div>
))
) : (
!isSearchingMeds && (
<div className="p-3 text-center text-muted">
<i className="bi bi-search fs-4 d-block mb-1 text-secondary"></i>
<div className="small fw-semibold">No medicine found matching "{r.item_name}"</div>
<div className="text-muted" style={{ fontSize: '11px' }}>Click below to add it directly into Pharmacy Master</div>
</div>
)
)}
{/* Pinned Add to Master Option */}
<div className="p-2 bg-light border-top text-center">
<button
type="button"
className="btn btn-sm btn-outline-primary w-100 fw-bold py-1"
style={{ fontSize: '12px' }}
onMouseDown={(e) => {
e.preventDefault();
openAddMasterModal(i, r.item_name);
}}
>
<i className="bi bi-plus-circle-fill me-1"></i>+ Add "{r.item_name || 'New Medicine'}" in Master
</button>
</div>
</div>
)}
</td>
<td>
<input type="text" className="form-control form-control-sm" placeholder="Batch" value={r.batch_no} onChange={(e) => updateRow(i, 'batch_no', e.target.value)} />
<input
type="text"
id={`batch-input-${i}`}
className="form-control form-control-sm"
placeholder="Batch No"
value={r.batch_no}
onChange={(e) => updateRow(i, 'batch_no', e.target.value)}
/>
</td>
<td>
<input type="date" className="form-control form-control-sm" value={r.expiry_date} onChange={(e) => updateRow(i, 'expiry_date', e.target.value)} />
<input
type="date"
className="form-control form-control-sm"
value={r.expiry_date}
onChange={(e) => updateRow(i, 'expiry_date', e.target.value)}
/>
</td>
<td>
<input type="number" className="form-control form-control-sm" min="1" value={r.qty_packs} onChange={(e) => updateRow(i, 'qty_packs', e.target.value)} />
@ -4326,10 +4629,11 @@ function PurchaseView({ store, notify }) {
<option value="5">5%</option>
<option value="12">12%</option>
<option value="18">18%</option>
<option value="28">28%</option>
</select>
</td>
<td>
<button className="btn btn-sm btn-outline-danger py-0 px-1" onClick={() => removeRow(i)}>
<button className="btn btn-sm btn-outline-danger py-0 px-1" onClick={() => removeRow(i)} title="Remove row">
<i className="bi bi-x"></i>
</button>
</td>
@ -4715,6 +5019,186 @@ function PurchaseView({ store, notify }) {
</div>
</div>
)}
{/* Medicine Master Add Modal */}
{showMasterModal && (
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(15, 23, 42, 0.75)' }} tabIndex="-1">
<div className="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div className="modal-content border-0 shadow-lg">
<div className="modal-header bg-dark text-white py-2.5 px-3 d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
<div className="bg-success text-white rounded p-1.5 d-flex align-items-center justify-content-center" style={{ width: 32, height: 32 }}>
<i className="bi bi-capsule fs-5"></i>
</div>
<div>
<h6 className="modal-title mb-0 fw-bold">Add New Medicine to Pharmacy Master</h6>
<small className="text-light opacity-75">Saved to shared master <code>med_product_master</code> (shared between MedicalStore &amp; HMSPharmacy)</small>
</div>
</div>
<button type="button" className="btn-close btn-close-white" onClick={() => setShowMasterModal(false)}></button>
</div>
<form onSubmit={handleSaveMasterMedicine}>
<div className="modal-body p-3 bg-light">
<div className="card border-0 shadow-sm p-3 mb-3 bg-white">
<h6 className="fw-bold text-primary mb-2" style={{ fontSize: '13px' }}>
<i className="bi bi-prescription2 me-1"></i> Medicine Name &amp; Formulation
</h6>
<div className="row g-2">
<div className="col-md-7">
<label className="form-label small fw-bold mb-1">Brand / Medicine Name <span className="text-danger">*</span></label>
<input
type="text"
className="form-control form-control-sm fw-semibold"
placeholder="e.g. Augmentin 625 Duo Tablet"
value={masterForm.item_name}
onChange={(e) => setMasterForm({ ...masterForm, item_name: e.target.value })}
required
autoFocus
/>
</div>
<div className="col-md-5">
<label className="form-label small fw-bold mb-1">Formulation / Category</label>
<input
type="text"
list="ms-formulation-list"
className="form-control form-control-sm"
placeholder="e.g. Tablet, Capsule, Syrup..."
value={masterForm.formulation}
onChange={(e) => setMasterForm({ ...masterForm, formulation: e.target.value })}
/>
<datalist id="ms-formulation-list">
{(supportData.formulations || []).map((f, idx) => (
<option key={idx} value={f} />
))}
</datalist>
</div>
<div className="col-12">
<label className="form-label small fw-bold mb-1">Generic Molecule Composition</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="e.g. Amoxycillin (500mg) + Clavulanic Acid (125mg)"
value={masterForm.generic_name}
onChange={(e) => setMasterForm({ ...masterForm, generic_name: e.target.value })}
/>
</div>
</div>
</div>
<div className="card border-0 shadow-sm p-3 bg-white">
<h6 className="fw-bold text-primary mb-2" style={{ fontSize: '13px' }}>
<i className="bi bi-box-seam me-1"></i> Packing, Tax &amp; Regulatory Specifications
</h6>
<div className="row g-2">
<div className="col-md-3">
<label className="form-label small fw-bold mb-1">Units per Pack (Strip/Bottle) <span className="text-danger">*</span></label>
<input
type="number"
min="1"
className="form-control form-control-sm fw-bold"
placeholder="10"
value={masterForm.units_per_pack}
onChange={(e) => setMasterForm({ ...masterForm, units_per_pack: e.target.value, packing: e.target.value })}
required
/>
</div>
<div className="col-md-3">
<label className="form-label small fw-bold mb-1">GST Rate (%)</label>
<select
className="form-select form-select-sm fw-bold"
value={masterForm.gst_rate}
onChange={(e) => setMasterForm({ ...masterForm, gst_rate: Number(e.target.value) })}
>
{(supportData.gst_rates || [0, 5, 12, 18, 28]).map((g, idx) => (
<option key={idx} value={g}>{g}% GST</option>
))}
</select>
</div>
<div className="col-md-3">
<label className="form-label small fw-bold mb-1">HSN Code</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="3004"
value={masterForm.hsn_code}
onChange={(e) => setMasterForm({ ...masterForm, hsn_code: e.target.value })}
/>
</div>
<div className="col-md-3">
<label className="form-label small fw-bold mb-1">Drug Schedule</label>
<select
className="form-select form-select-sm"
value={masterForm.drug_schedule}
onChange={(e) => setMasterForm({ ...masterForm, drug_schedule: e.target.value })}
>
{(supportData.schedules || ['OTC', 'Schedule H', 'Schedule H1', 'Schedule X', 'Narcotic']).map((sc, idx) => (
<option key={idx} value={sc}>{sc}</option>
))}
</select>
</div>
<div className="col-md-8">
<label className="form-label small fw-bold mb-1">Manufacturer / Pharma Company</label>
<input
type="text"
list="ms-company-list"
className="form-control form-control-sm"
placeholder="e.g. Cipla Ltd, Sun Pharma, Alkem..."
value={masterForm.company_name}
onChange={(e) => setMasterForm({ ...masterForm, company_name: e.target.value })}
/>
<datalist id="ms-company-list">
{(supportData.companies || []).map((c, idx) => (
<option key={idx} value={c} />
))}
</datalist>
</div>
<div className="col-md-4">
<label className="form-label small fw-bold mb-1">Min Reorder Level</label>
<input
type="number"
min="1"
className="form-control form-control-sm"
placeholder="10"
value={masterForm.min_reorder_level}
onChange={(e) => setMasterForm({ ...masterForm, min_reorder_level: e.target.value })}
/>
</div>
</div>
</div>
</div>
<div className="modal-footer py-2 px-3 bg-white d-flex justify-content-between">
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => setShowMasterModal(false)}
>
Cancel
</button>
<button
type="submit"
className="btn btn-success btn-sm fw-bold px-4"
disabled={isSavingMaster}
>
{isSavingMaster ? (
<>
<span className="spinner-border spinner-border-sm me-1" role="status"></span>
Saving to Master...
</>
) : (
<>
<i className="bi bi-check-circle me-1"></i>
Save to Master &amp; Select into Bill
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}