Compare commits

...

8 Commits

Author SHA1 Message Date
Flatlogic Bot
08314098a2 Revert to version 71327bd 2026-02-20 16:10:40 +00:00
Flatlogic Bot
96bbc6c936 Revert to version df33a81 2026-02-20 16:10:07 +00:00
Flatlogic Bot
c024881caa Revert to version 2255911 2026-02-20 16:09:40 +00:00
Flatlogic Bot
df33a81c67 Edit backend/src/db/db.config.js via Editor 2026-02-20 16:08:26 +00:00
Flatlogic Bot
22559112f0 Autosave: 20260220-143017 2026-02-20 14:30:18 +00:00
Flatlogic Bot
71327bddd8 Revert to version 1251517 2026-02-17 19:39:35 +00:00
Flatlogic Bot
2d0d1de32d Edit backend/.env via Editor 2026-02-17 19:38:07 +00:00
Flatlogic Bot
1251517227 Autosave: 20260217-170047 2026-02-17 17:00:47 +00:00
15 changed files with 20145 additions and 925 deletions

17266
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"chokidar": "^4.0.3",
"cors": "2.8.5",
"csv-parser": "^3.0.0",
"exceljs": "^4.4.0",
"express": "4.18.2",
"formidable": "1.2.2",
"helmet": "4.1.1",

View File

@ -296,7 +296,7 @@ const DataEntitiesData = [
"code": "customers",
"code": "products",
@ -342,7 +342,7 @@ const DataEntitiesData = [
"code": "products",
"code": "customers",
@ -434,7 +434,7 @@ const DataEntitiesData = [
"code": "customers",
"code": "availability",

View File

@ -20,4 +20,27 @@ module.exports = class Helpers {
static jwtSign(data) {
return jwt.sign(data, config.secret_key, {expiresIn: '6h'});
};
static async exportData(res, type, entityName, fields, rows) {
if (type === 'csv') {
const { parse } = require('json2csv');
const csv = parse(rows, { fields });
res.header('Content-Type', 'text/csv');
res.attachment(`${entityName}.csv`);
return res.send(csv);
} else if (type === 'xlsx') {
const ExcelJS = require('exceljs');
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(entityName);
worksheet.columns = fields.map(field => ({ header: field, key: field }));
worksheet.addRows(rows);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${entityName}.xlsx`);
await workbook.xlsx.write(res);
return res.end();
}
}
};

View File

@ -3,15 +3,14 @@ const express = require('express');
const Availability_recordsService = require('../services/availability_records');
const Availability_recordsDBApi = require('../db/api/availability_records');
const wrapAsync = require('../helpers').wrapAsync;
const Helpers = require('../helpers');
const wrapAsync = Helpers.wrapAsync;
const config = require('../config');
const router = express.Router();
const { parse } = require('json2csv');
const {
checkCrudPermissions,
@ -303,20 +302,16 @@ router.get('/', wrapAsync(async (req, res) => {
const payload = await Availability_recordsDBApi.findAll(
req.query, globalAccess, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id','location_code','product_sku','source_reference',
'quantity_on_hand','quantity_available',
'as_of',
];
const opts = { fields };
if (filetype && (filetype === 'csv' || filetype === 'xlsx')) {
let fields = ['id','location_code','product_sku','source_reference', 'quantity_on_hand','quantity_available', 'as_of'];
if (req.query.fields) {
fields = req.query.fields.split(',');
}
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv)
await Helpers.exportData(res, filetype, 'availability_records', fields, payload.rows);
} catch (err) {
console.error(err);
res.status(500).send('Export failed');
}
} else {
res.status(200).send(payload);

View File

@ -3,15 +3,14 @@ const express = require('express');
const CustomersService = require('../services/customers');
const CustomersDBApi = require('../db/api/customers');
const wrapAsync = require('../helpers').wrapAsync;
const Helpers = require('../helpers');
const wrapAsync = Helpers.wrapAsync;
const config = require('../config');
const router = express.Router();
const { parse } = require('json2csv');
const {
checkCrudPermissions,
@ -307,20 +306,16 @@ router.get('/', wrapAsync(async (req, res) => {
const payload = await CustomersDBApi.findAll(
req.query, globalAccess, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id','customer_code','name','email','phone','billing_address','shipping_address',
];
const opts = { fields };
if (filetype && (filetype === 'csv' || filetype === 'xlsx')) {
let fields = ['id','customer_code','name','email','phone','billing_address','shipping_address'];
if (req.query.fields) {
fields = req.query.fields.split(',');
}
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv)
await Helpers.exportData(res, filetype, 'customers', fields, payload.rows);
} catch (err) {
console.error(err);
res.status(500).send('Export failed');
}
} else {
res.status(200).send(payload);

View File

@ -3,15 +3,14 @@ const express = require('express');
const ProductsService = require('../services/products');
const ProductsDBApi = require('../db/api/products');
const wrapAsync = require('../helpers').wrapAsync;
const Helpers = require('../helpers');
const wrapAsync = Helpers.wrapAsync;
const config = require('../config');
const router = express.Router();
const { parse } = require('json2csv');
const {
checkCrudPermissions,
@ -307,20 +306,16 @@ router.get('/', wrapAsync(async (req, res) => {
const payload = await ProductsDBApi.findAll(
req.query, globalAccess, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id','sku','name','description','uom','currency',
'price',
];
const opts = { fields };
if (filetype && (filetype === 'csv' || filetype === 'xlsx')) {
let fields = ['id','sku','name','description','uom','currency', 'price'];
if (req.query.fields) {
fields = req.query.fields.split(',');
}
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv)
await Helpers.exportData(res, filetype, 'products', fields, payload.rows);
} catch (err) {
console.error(err);
res.status(500).send('Export failed');
}
} else {
res.status(200).send(payload);

View File

@ -3,15 +3,14 @@ const express = require('express');
const SuppliersService = require('../services/suppliers');
const SuppliersDBApi = require('../db/api/suppliers');
const wrapAsync = require('../helpers').wrapAsync;
const Helpers = require('../helpers');
const wrapAsync = Helpers.wrapAsync;
const config = require('../config');
const router = express.Router();
const { parse } = require('json2csv');
const {
checkCrudPermissions,
@ -304,20 +303,16 @@ router.get('/', wrapAsync(async (req, res) => {
const payload = await SuppliersDBApi.findAll(
req.query, globalAccess, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id','supplier_code','name','email','phone','address',
];
const opts = { fields };
if (filetype && (filetype === 'csv' || filetype === 'xlsx')) {
let fields = ['id','supplier_code','name','email','phone','address'];
if (req.query.fields) {
fields = req.query.fields.split(',');
}
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv)
await Helpers.exportData(res, filetype, 'suppliers', fields, payload.rows);
} catch (err) {
console.error(err);
res.status(500).send('Export failed');
}
} else {
res.status(200).send(payload);

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,16 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/imports',
label: 'Import Wizard',
icon: icon.mdiFileUploadOutline,
},
{
href: '/export-wizard',
label: 'Export Wizard',
icon: icon.mdiFileExportOutline,
},
{
href: '/users/users-list',

View File

@ -65,7 +65,31 @@ const CustomersTablesPage = () => {
};
const getCustomersCSV = async () => {
const response = await axios({url: '/customers?filetype=csv', method: 'GET',responseType: 'blob'});
let filterQuery = '';
filterItems.forEach((item) => {
const isRangeFilter = filters.find(
(filter) =>
filter.title === item.fields.selectedField &&
(filter.number || filter.date),
);
if (isRangeFilter) {
const from = item.fields.filterValueFrom;
const to = item.fields.filterValueTo;
if (from) {
filterQuery += `&${item.fields.selectedField}Range=${from}`;
}
if (to) {
filterQuery += `&${item.fields.selectedField}Range=${to}`;
}
} else {
const value = item.fields.filterValue;
if (value) {
filterQuery += `&${item.fields.selectedField}=${value}`;
}
}
});
const response = await axios({url: `/customers?filetype=csv${filterQuery}`, method: 'GET',responseType: 'blob'});
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')

View File

@ -65,7 +65,31 @@ const CustomersTablesPage = () => {
};
const getCustomersCSV = async () => {
const response = await axios({url: '/customers?filetype=csv', method: 'GET',responseType: 'blob'});
let filterQuery = '';
filterItems.forEach((item) => {
const isRangeFilter = filters.find(
(filter) =>
filter.title === item.fields.selectedField &&
(filter.number || filter.date),
);
if (isRangeFilter) {
const from = item.fields.filterValueFrom;
const to = item.fields.filterValueTo;
if (from) {
filterQuery += `&${item.fields.selectedField}Range=${from}`;
}
if (to) {
filterQuery += `&${item.fields.selectedField}Range=${to}`;
}
} else {
const value = item.fields.filterValue;
if (value) {
filterQuery += `&${item.fields.selectedField}=${value}`;
}
}
});
const response = await axios({url: `/customers?filetype=csv${filterQuery}`, method: 'GET',responseType: 'blob'});
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')

View File

@ -0,0 +1,172 @@
import {
mdiFileExportOutline,
mdiPackageVariantClosed,
mdiAccountGroupOutline,
mdiTruckOutline,
mdiWarehouse,
mdiChevronRight,
mdiChevronLeft,
mdiDownload,
} from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useState } from 'react'
import CardBox from '../components/CardBox'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import { getPageTitle } from '../config'
import BaseButton from '../components/BaseButton'
import BaseButtons from '../components/BaseButtons'
import FormField from '../components/FormField'
import axios from 'axios'
import BaseIcon from '../components/BaseIcon'
const entities = [
{ id: 'products', label: 'Products', icon: mdiPackageVariantClosed, fields: ['id', 'tenant', 'sku', 'name', 'description', 'uom', 'currency', 'price'] },
{ id: 'customers', label: 'Customers', icon: mdiAccountGroupOutline, fields: ['id', 'tenant', 'customer_code', 'name', 'email', 'phone', 'billing_address', 'shipping_address'] },
{ id: 'suppliers', label: 'Suppliers', icon: mdiTruckOutline, fields: ['id', 'tenant', 'supplier_code', 'name', 'email', 'phone', 'address'] },
{ id: 'availability_records', label: 'Availability records', icon: mdiWarehouse, fields: ['id', 'tenant', 'location_code', 'product_sku', 'source_reference', 'quantity_on_hand', 'quantity_available', 'as_of'] },
]
const ExportWizard = () => {
const [step, setStep] = useState(1)
const [selectedEntity, setSelectedEntity] = useState('')
const [selectedFields, setSelectedFields] = useState<string[]>([])
const currentEntity = entities.find((e) => e.id === selectedEntity)
const handleEntitySelect = (entityId: string) => {
setSelectedEntity(entityId)
const entity = entities.find((e) => e.id === entityId)
if (entity) {
setSelectedFields(entity.fields)
}
setStep(2)
}
const toggleField = (field: string) => {
if (selectedFields.includes(field)) {
setSelectedFields(selectedFields.filter((f) => f !== field))
} else {
setSelectedFields([...selectedFields, field])
}
}
const handleExport = async () => {
try {
const response = await axios.get(`/${selectedEntity}`, {
params: {
filetype: 'xlsx',
fields: selectedFields.join(','),
},
responseType: 'blob',
})
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `${selectedEntity}_export_${new Date().toISOString().split('T')[0]}.xlsx`)
document.body.appendChild(link)
link.click()
link.remove()
} catch (error) {
console.error('Export failed', error)
alert('Export failed. Please try again.')
}
}
return (
<>
<Head>
<title>{getPageTitle('Export Wizard')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiFileExportOutline} title="Export Wizard" main>
{''}
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<div className="flex items-center justify-between mb-8">
<div className={`flex flex-col items-center ${step >= 1 ? 'text-blue-600' : 'text-gray-400'}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${step >= 1 ? 'border-blue-600 bg-blue-50' : 'border-gray-300'}`}>1</div>
<span className="text-xs mt-1 font-bold">Select Entity</span>
</div>
<div className={`flex-1 h-1 mx-4 ${step >= 2 ? 'bg-blue-600' : 'bg-gray-200'}`}></div>
<div className={`flex flex-col items-center ${step >= 2 ? 'text-blue-600' : 'text-gray-400'}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${step >= 2 ? 'border-blue-600 bg-blue-50' : 'border-gray-300'}`}>2</div>
<span className="text-xs mt-1 font-bold">Select Fields</span>
</div>
<div className={`flex-1 h-1 mx-4 ${step >= 3 ? 'bg-blue-600' : 'bg-gray-200'}`}></div>
<div className={`flex flex-col items-center ${step >= 3 ? 'text-blue-600' : 'text-gray-400'}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${step >= 3 ? 'border-blue-600 bg-blue-50' : 'border-gray-300'}`}>3</div>
<span className="text-xs mt-1 font-bold">Download</span>
</div>
</div>
{step === 1 && (
<div>
<h2 className="text-xl font-bold mb-4">What would you like to export?</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{entities.map((entity) => (
<div
key={entity.id}
onClick={() => handleEntitySelect(entity.id)}
className="p-6 border-2 rounded-lg cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition-colors flex flex-col items-center text-center group"
>
<BaseIcon path={entity.icon} size={48} className="mb-4 text-gray-500 group-hover:text-blue-500" />
<span className="font-bold">{entity.label}</span>
</div>
))}
</div>
</div>
)}
{step === 2 && currentEntity && (
<div>
<div className="flex items-center mb-4">
<BaseIcon path={currentEntity.icon} className="mr-2" />
<h2 className="text-xl font-bold">Select fields for {currentEntity.label}</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-8">
{currentEntity.fields.map((field) => (
<label key={field} className="flex items-center space-x-3 p-3 border rounded hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selectedFields.includes(field)}
onChange={() => toggleField(field)}
className="form-checkbox h-5 w-5 text-blue-600"
/>
<span className="capitalize">{field.replace('_', ' ')}</span>
</label>
))}
</div>
<BaseButtons>
<BaseButton label="Back" icon={mdiChevronLeft} onClick={() => setStep(1)} />
<BaseButton label="Next" color="info" icon={mdiChevronRight} onClick={() => setStep(3)} disabled={selectedFields.length === 0} />
</BaseButtons>
</div>
)}
{step === 3 && currentEntity && (
<div className="text-center py-8">
<BaseIcon path={mdiDownload} size={64} className="mb-4 text-blue-500 mx-auto" />
<h2 className="text-2xl font-bold mb-2">Ready to export!</h2>
<p className="text-gray-500 mb-8">
You are about to export <strong>{currentEntity.label}</strong> with <strong>{selectedFields.length}</strong> fields.
</p>
<BaseButtons className="justify-center">
<BaseButton label="Back" icon={mdiChevronLeft} onClick={() => setStep(2)} />
<BaseButton label="Download XLSX" color="success" icon={mdiDownload} onClick={handleExport} />
</BaseButtons>
</div>
)}
</CardBox>
</SectionMain>
</>
)
}
ExportWizard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default ExportWizard

View File

@ -0,0 +1,92 @@
import { mdiFileUploadOutline, mdiPackageVariantClosed, mdiAccountGroupOutline, mdiTruckOutline, mdiWarehouse } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import axios from 'axios';
import BaseButton from '../../components/BaseButton';
const ImportWizard = () => {
const [entities, setEntities] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchEntities = async () => {
try {
const response = await axios.get('/data_entities');
setEntities(response.data.rows || []);
} catch (error) {
console.error('Failed to fetch entities', error);
} finally {
setLoading(false);
}
};
fetchEntities();
}, []);
const getIcon = (code: string) => {
switch (code) {
case 'products': return mdiPackageVariantClosed;
case 'customers': return mdiAccountGroupOutline;
case 'suppliers': return mdiTruckOutline;
case 'availability': return mdiWarehouse;
default: return mdiFileUploadOutline;
}
};
return (
<>
<Head>
<title>{getPageTitle('Import Wizard')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiFileUploadOutline} title='Import Wizard' main>
{''}
</SectionTitleLineWithButton>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{loading ? (
<p>Loading entities...</p>
) : (
entities.map((entity: any) => (
<CardBox key={entity.id} className="hover:shadow-lg transition-shadow border-2 border-transparent hover:border-indigo-500">
<div className="flex flex-col items-center text-center p-4">
<div className="bg-indigo-100 p-4 rounded-full mb-4">
<svg viewBox="0 0 24 24" className="w-12 h-12 text-indigo-600">
<path fill="currentColor" d={getIcon(entity.code)} />
</svg>
</div>
<h3 className="text-xl font-bold mb-2">{entity.name}</h3>
<p className="text-gray-500 text-sm mb-4 h-10">{entity.description}</p>
<BaseButton
color="info"
label="Start Import"
className="w-full"
href={`/imports/upload?entityId=${entity.id}&code=${entity.code}`}
/>
</div>
</CardBox>
))
)}
</div>
{!loading && entities.length === 0 && (
<CardBox>
<div className="text-center py-12">
<p className="text-gray-500 italic">No entities available for import. Please contact admin.</p>
</div>
</CardBox>
)}
</SectionMain>
</>
);
};
ImportWizard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ImportWizard;

View File

@ -0,0 +1,118 @@
import { mdiArrowLeft, mdiFileUploadOutline, mdiCheckCircle, mdiAlertCircle } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useState } from 'react';
import { useRouter } from 'next/router';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import BaseButton from '../../components/BaseButton';
import BaseIcon from '../../components/BaseIcon';
const ImportUpload = () => {
const router = useRouter();
const { code } = router.query;
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0];
if (selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.xls') || selectedFile.name.endsWith('.xlsx')) {
setFile(selectedFile);
setError(null);
} else {
setError('Please select a valid CSV or Excel file.');
setFile(null);
}
}
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
// In a real app, we would upload the file here.
// For now, we'll simulate a successful upload and redirect to a mapping page (next task)
setTimeout(() => {
setUploading(false);
alert('File uploaded successfully! Next: Column Mapping (Coming Soon)');
router.push('/imports');
}, 1500);
};
return (
<>
<Head>
<title>{getPageTitle(`Upload ${code}`)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiFileUploadOutline} title={`Upload ${code} Data`} main>
<BaseButton href="/imports" icon={mdiArrowLeft} label="Back" color="white" />
</SectionTitleLineWithButton>
<CardBox className="max-w-2xl mx-auto">
<div className="space-y-6">
<div className="text-center p-8 border-2 border-dashed border-gray-300 rounded-lg bg-gray-50">
<BaseIcon path={mdiFileUploadOutline} size={48} className="mx-auto text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-700">Select file to import for {code}</p>
<p className="text-sm text-gray-500 mb-6">Supported formats: CSV, XLS, XLSX</p>
<input
type="file"
id="file-upload"
className="hidden"
accept=".csv, .xls, .xlsx"
onChange={handleFileChange}
/>
<label
htmlFor="file-upload"
className="cursor-pointer bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Browse Files
</label>
{file && (
<div className="mt-4 flex items-center justify-center text-indigo-600 font-medium">
<BaseIcon path={mdiCheckCircle} size={20} className="mr-2" />
{file.name}
</div>
)}
{error && (
<div className="mt-4 flex items-center justify-center text-red-600 font-medium">
<BaseIcon path={mdiAlertCircle} size={20} className="mr-2" />
{error}
</div>
)}
</div>
<div className="bg-blue-50 p-4 rounded-md">
<h4 className="text-blue-800 font-bold mb-1">Import Rules for {code}:</h4>
<ul className="text-blue-700 text-sm list-disc list-inside">
<li>File must include a header row.</li>
<li>Required fields must not be empty.</li>
<li>Data will be validated against tenant-specific rules.</li>
</ul>
</div>
<BaseButton
color="info"
label={uploading ? "Uploading..." : "Process File"}
disabled={!file || uploading}
className="w-full"
onClick={handleUpload}
/>
</div>
</CardBox>
</SectionMain>
</>
);
};
ImportUpload.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ImportUpload;