2138 lines
79 KiB
JavaScript
2138 lines
79 KiB
JavaScript
|
|
const express = require('express');
|
|||
|
|
const cors = require('cors');
|
|||
|
|
const multer = require('multer');
|
|||
|
|
const axios = require('axios');
|
|||
|
|
const FormData = require('form-data');
|
|||
|
|
|
|||
|
|
const app = express();
|
|||
|
|
const port = process.env.PORT || 3000;
|
|||
|
|
|
|||
|
|
const config = {
|
|||
|
|
ncodbUrl: process.env.NOCODB_URL || 'https://noco.quadjourney.com',
|
|||
|
|
ncodbPublicUrl: process.env.NOCODB_PUBLIC_URL || process.env.NOCODB_URL || 'https://noco.quadjourney.com',
|
|||
|
|
apiToken: process.env.NOCODB_API_TOKEN || '',
|
|||
|
|
baseId: process.env.NOCODB_BASE_ID || 'pava9q9zccyihpt',
|
|||
|
|
tableId: process.env.NOCODB_TABLE_ID || 'mash7c5nx4unukc',
|
|||
|
|
columnName: process.env.NOCODB_COLUMN_NAME || 'photos',
|
|||
|
|
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL || '',
|
|||
|
|
discordUsername: process.env.DISCORD_USERNAME || 'NocoDB Uploader',
|
|||
|
|
discordAvatarUrl: process.env.DISCORD_AVATAR_URL || '',
|
|||
|
|
publicAppUrl: process.env.PUBLIC_APP_URL || `http://localhost:${process.env.EXTERNAL_PORT || port}`,
|
|||
|
|
sendToPhoneToken: process.env.SEND_TO_PHONE_TOKEN || '',
|
|||
|
|
workspaceId: '' // fetched at startup
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.use(cors());
|
|||
|
|
app.use(express.json());
|
|||
|
|
// Allow form-encoded payloads from NocoDB webhook buttons
|
|||
|
|
app.use(express.urlencoded({ extended: true }));
|
|||
|
|
|
|||
|
|
const upload = multer({
|
|||
|
|
storage: multer.memoryStorage(),
|
|||
|
|
limits: { fileSize: 10 * 1024 * 1024 }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function extractRowFromPayload(body = {}) {
|
|||
|
|
if (!body || typeof body !== 'object') return null;
|
|||
|
|
// Common NocoDB webhook shapes
|
|||
|
|
if (body.data?.rows?.length) return body.data.rows[0];
|
|||
|
|
if (body.data?.records?.length) return body.data.records[0];
|
|||
|
|
if (body.data?.previous_rows?.length) return body.data.previous_rows[0];
|
|||
|
|
if (Array.isArray(body.rows) && body.rows.length) return body.rows[0];
|
|||
|
|
if (body.row) return body.row;
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Optional per-request shared secret for send-to-phone endpoint
|
|||
|
|
function isAuthorized(req) {
|
|||
|
|
if (!config.sendToPhoneToken) return true;
|
|||
|
|
const provided =
|
|||
|
|
(req.query && (req.query.token || req.query.auth)) ||
|
|||
|
|
(req.body && (req.body.token || req.body.auth)) ||
|
|||
|
|
req.headers['x-send-to-phone-token'];
|
|||
|
|
return provided === config.sendToPhoneToken;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildDeepLink(rowId, itemName) {
|
|||
|
|
const base = (config.publicAppUrl || '').replace(/\/$/, '') || `http://localhost:${port}`;
|
|||
|
|
const params = new URLSearchParams({ rowId: String(rowId) });
|
|||
|
|
if (itemName) {
|
|||
|
|
params.set('item', itemName);
|
|||
|
|
}
|
|||
|
|
return `${base}?${params.toString()}`;
|
|||
|
|
}
|
|||
|
|
const safeField = (value, fallback = 'Unknown') => (value ? String(value) : fallback);
|
|||
|
|
|
|||
|
|
async function fetchRowByFilter(rowId) {
|
|||
|
|
const headers = {
|
|||
|
|
'xc-token': config.apiToken,
|
|||
|
|
'Accept': 'application/json'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Try row_id first (common UUID field), then Id as string
|
|||
|
|
const queries = [
|
|||
|
|
{ where: `(row_id,eq,${rowId})`, limit: 1 },
|
|||
|
|
{ where: `(Id,eq,${rowId})`, limit: 1 }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const params of queries) {
|
|||
|
|
try {
|
|||
|
|
const resp = await axios.get(
|
|||
|
|
`${config.ncodbUrl}/api/v1/db/data/noco/${config.baseId}/${config.tableId}`,
|
|||
|
|
{ headers, params }
|
|||
|
|
);
|
|||
|
|
const list = resp.data?.list || [];
|
|||
|
|
if (list.length > 0) return list[0];
|
|||
|
|
} catch (err) {
|
|||
|
|
// continue to next strategy
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchRowFlexible(rowId) {
|
|||
|
|
const headers = {
|
|||
|
|
'xc-token': config.apiToken,
|
|||
|
|
'Accept': 'application/json'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// First try direct PK fetch
|
|||
|
|
try {
|
|||
|
|
const resp = await axios.get(
|
|||
|
|
`${config.ncodbUrl}/api/v1/db/data/noco/${config.baseId}/${config.tableId}/${rowId}`,
|
|||
|
|
{ headers }
|
|||
|
|
);
|
|||
|
|
return resp.data;
|
|||
|
|
} catch (err) {
|
|||
|
|
const code = err.response?.data?.error;
|
|||
|
|
const status = err.response?.status;
|
|||
|
|
// If PK fails (common when Noco button sends row_id UUID), try filtering
|
|||
|
|
if (code === 'INVALID_PK_VALUE' || status === 404 || status === 400) {
|
|||
|
|
const byFilter = await fetchRowByFilter(rowId);
|
|||
|
|
if (byFilter) return byFilter;
|
|||
|
|
}
|
|||
|
|
throw err;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Search for records by item name
|
|||
|
|
app.get('/search-records', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (!config.apiToken) {
|
|||
|
|
return res.status(500).json({ error: 'NocoDB API token not configured' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const searchTerm = req.query.q;
|
|||
|
|
if (!searchTerm) {
|
|||
|
|
return res.status(400).json({ error: 'Search term required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const term = searchTerm.replace(/[%_]/g, ''); // sanitize wildcards
|
|||
|
|
|
|||
|
|
// Server-side search across key fields using NocoDB like filter
|
|||
|
|
const where = [
|
|||
|
|
`(Item,like,%${term}%)`,
|
|||
|
|
`(Order Number,like,%${term}%)`,
|
|||
|
|
`(SKU,like,%${term}%)`,
|
|||
|
|
`(Serial Numbers,like,%${term}%)`,
|
|||
|
|
`(Name,like,%${term}%)`,
|
|||
|
|
`(Tracking Number,like,%${term}%)`
|
|||
|
|
].join('~or');
|
|||
|
|
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
{
|
|||
|
|
headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' },
|
|||
|
|
params: { where, limit: 200, sort: '-Id' }
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const rows = response.data.list || [];
|
|||
|
|
|
|||
|
|
const searchResults = rows.map(row => {
|
|||
|
|
let displayText = row.Item || row.Name || 'Unknown Item';
|
|||
|
|
if (row['Order Number']) displayText += ' | Order: ' + row['Order Number'];
|
|||
|
|
if (row['Serial Numbers']) displayText += ' | SN: ' + row['Serial Numbers'];
|
|||
|
|
if (row.SKU) displayText += ' | SKU: ' + row.SKU;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id: row.Id,
|
|||
|
|
item: displayText,
|
|||
|
|
received: row.Received || 'Unknown Status'
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({ results: searchResults });
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Search error:', error.response?.data || error.message);
|
|||
|
|
res.status(500).json({ error: 'Search failed', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Test endpoint
|
|||
|
|
app.get('/test', (req, res) => {
|
|||
|
|
res.json({ message: 'Server is working!', timestamp: new Date() });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get single item details
|
|||
|
|
app.get('/item-details/:id', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const itemId = req.params.id;
|
|||
|
|
console.log('Fetching details for item ID:', itemId);
|
|||
|
|
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + itemId,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
'xc-token': config.apiToken,
|
|||
|
|
'Accept': 'application/json'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const nocodbRowUrl = config.workspaceId
|
|||
|
|
? `${config.ncodbPublicUrl}/dashboard/#/${config.workspaceId}/${config.baseId}/${config.tableId}?rowId=${itemId}`
|
|||
|
|
: config.ncodbPublicUrl;
|
|||
|
|
res.json({ success: true, item: response.data, nocodb_url: nocodbRowUrl });
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error fetching item details:', error.message);
|
|||
|
|
res.status(500).json({ error: 'Failed to fetch item details', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Send a Discord notification with a deep link for mobile upload
|
|||
|
|
app.all('/send-to-phone', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (!config.discordWebhookUrl) {
|
|||
|
|
return res.status(500).json({ error: 'Discord webhook not configured' });
|
|||
|
|
}
|
|||
|
|
if (!config.apiToken) {
|
|||
|
|
return res.status(500).json({ error: 'NocoDB API token not configured' });
|
|||
|
|
}
|
|||
|
|
if (!isAuthorized(req)) {
|
|||
|
|
return res.status(403).json({ error: 'Unauthorized' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payloadRow = extractRowFromPayload(req.body);
|
|||
|
|
let rowId = req.body?.rowId || req.body?.id || req.query.rowId || req.query.id;
|
|||
|
|
if (!rowId && payloadRow) {
|
|||
|
|
rowId = payloadRow.Id || payloadRow.id || payloadRow.row_id || payloadRow.rowId;
|
|||
|
|
}
|
|||
|
|
const primaryId = payloadRow?.Id || rowId; // Prefer numeric Id when available
|
|||
|
|
console.log('Send-to-phone request payload:', {
|
|||
|
|
rowId,
|
|||
|
|
primaryId,
|
|||
|
|
query: req.query,
|
|||
|
|
body: req.body,
|
|||
|
|
payloadRow
|
|||
|
|
});
|
|||
|
|
if (!rowId) {
|
|||
|
|
return res.status(400).json({ error: 'Missing rowId/id' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fetch row to confirm and enrich the message (with fallback for row_id UUIDs)
|
|||
|
|
const item = payloadRow || await fetchRowFlexible(primaryId);
|
|||
|
|
const itemName = item.Item || item.Name || '';
|
|||
|
|
const orderNumber = item['Order Number'] || '';
|
|||
|
|
const sku = item.SKU || '';
|
|||
|
|
const serials = item['Serial Numbers'] || '';
|
|||
|
|
const status = item.Received || item.received || 'Unknown';
|
|||
|
|
|
|||
|
|
const deepLink = buildDeepLink(primaryId, itemName);
|
|||
|
|
const contentLines = [
|
|||
|
|
`📲 Upload photos from your phone`,
|
|||
|
|
`Item: ${safeField(itemName, 'Unknown')}`,
|
|||
|
|
`Row ID: ${primaryId}`,
|
|||
|
|
`Link: ${deepLink}`
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const embedFields = [
|
|||
|
|
{ name: 'Row ID', value: String(primaryId), inline: true }
|
|||
|
|
];
|
|||
|
|
if (orderNumber) embedFields.push({ name: 'Order #', value: String(orderNumber), inline: true });
|
|||
|
|
if (sku) embedFields.push({ name: 'SKU', value: String(sku), inline: true });
|
|||
|
|
if (serials) embedFields.push({ name: 'Serials', value: String(serials).slice(0, 1000) });
|
|||
|
|
if (status) embedFields.push({ name: 'Status', value: String(status), inline: true });
|
|||
|
|
|
|||
|
|
const payload = {
|
|||
|
|
content: contentLines.join('\n'),
|
|||
|
|
username: config.discordUsername || undefined,
|
|||
|
|
avatar_url: config.discordAvatarUrl || undefined,
|
|||
|
|
embeds: [
|
|||
|
|
{
|
|||
|
|
title: itemName || 'Item',
|
|||
|
|
description: 'Tap the link to open the uploader on your phone.',
|
|||
|
|
url: deepLink,
|
|||
|
|
fields: embedFields,
|
|||
|
|
footer: { text: 'NocoDB Photo Uploader' }
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await axios.post(config.discordWebhookUrl, payload);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
message: 'Sent to Discord',
|
|||
|
|
link: deepLink,
|
|||
|
|
item: {
|
|||
|
|
id: rowId,
|
|||
|
|
name: itemName,
|
|||
|
|
orderNumber,
|
|||
|
|
sku,
|
|||
|
|
serials,
|
|||
|
|
status
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Send-to-phone error:', error.response?.data || error.message);
|
|||
|
|
res.status(500).json({ error: 'Failed to send link', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Summary endpoint — server-side filtered, fast single query
|
|||
|
|
app.get('/summary', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (!config.apiToken) {
|
|||
|
|
return res.status(500).json({ error: 'NocoDB API token not configured' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
{
|
|||
|
|
headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' },
|
|||
|
|
params: {
|
|||
|
|
where: '(Received,eq,Issues)~or(Received,eq,Issue)~or(Received,eq,Needs Review)',
|
|||
|
|
limit: 200
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const rows = response.data.list || [];
|
|||
|
|
|
|||
|
|
const mapItem = (row) => ({
|
|||
|
|
id: row.Id,
|
|||
|
|
item: row.Item || row.Name || 'Unknown',
|
|||
|
|
orderNumber: row['Order Number'] || '',
|
|||
|
|
serialNumbers: row['Serial Numbers'] || '',
|
|||
|
|
sku: row.SKU || '',
|
|||
|
|
received: row.Received || row.received || '',
|
|||
|
|
trackingNumber: row['Tracking Number'] || '',
|
|||
|
|
notes: row.Notes || ''
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const issues = [];
|
|||
|
|
const needsReview = [];
|
|||
|
|
for (const row of rows) {
|
|||
|
|
const val = (row.Received || row.received || '').toLowerCase();
|
|||
|
|
if (val === 'issues' || val === 'issue') issues.push(mapItem(row));
|
|||
|
|
else if (val === 'needs review') needsReview.push(mapItem(row));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
issues,
|
|||
|
|
issueCount: issues.length,
|
|||
|
|
needsReview,
|
|||
|
|
reviewCount: needsReview.length
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Summary fetch error:', error.message);
|
|||
|
|
res.status(500).json({ error: 'Failed to fetch summary', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Debug endpoint to check NocoDB connection and data
|
|||
|
|
app.get('/debug-nocodb', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
console.log('Debug: Testing NocoDB connection...');
|
|||
|
|
console.log('Config:', {
|
|||
|
|
url: config.ncodbUrl,
|
|||
|
|
baseId: config.baseId,
|
|||
|
|
tableId: config.tableId,
|
|||
|
|
hasToken: !!config.apiToken
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Fetch all records with pagination
|
|||
|
|
let allRows = [];
|
|||
|
|
let offset = 0;
|
|||
|
|
const limit = 1000;
|
|||
|
|
let hasMore = true;
|
|||
|
|
|
|||
|
|
while (hasMore && offset < 10000) {
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
'xc-token': config.apiToken,
|
|||
|
|
'Accept': 'application/json'
|
|||
|
|
},
|
|||
|
|
params: {
|
|||
|
|
limit: limit,
|
|||
|
|
offset: offset
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const pageRows = response.data.list || [];
|
|||
|
|
allRows = allRows.concat(pageRows);
|
|||
|
|
hasMore = pageRows.length === limit;
|
|||
|
|
offset += limit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
totalRowsRetrieved: allRows.length,
|
|||
|
|
sampleRow: allRows[0] || null,
|
|||
|
|
fieldNames: allRows.length > 0 ? Object.keys(allRows[0]) : [],
|
|||
|
|
firstThreeRows: allRows.slice(0, 3),
|
|||
|
|
message: `Successfully retrieved ${allRows.length} total records`
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Debug error:', error.message);
|
|||
|
|
res.status(500).json({
|
|||
|
|
error: 'Failed to connect to NocoDB',
|
|||
|
|
message: error.message,
|
|||
|
|
responseData: error.response?.data,
|
|||
|
|
status: error.response?.status
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get items with issues (full details)
|
|||
|
|
app.get('/issues', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (!config.apiToken) {
|
|||
|
|
return res.status(500).json({ error: 'NocoDB API token not configured' });
|
|||
|
|
}
|
|||
|
|
let allRows = [];
|
|||
|
|
let offset = 0;
|
|||
|
|
const limit = 1000;
|
|||
|
|
let hasMore = true;
|
|||
|
|
while (hasMore && offset < 10000) {
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
{ headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' }, params: { limit, offset } }
|
|||
|
|
);
|
|||
|
|
const pageRows = response.data.list || [];
|
|||
|
|
allRows = allRows.concat(pageRows);
|
|||
|
|
hasMore = pageRows.length === limit;
|
|||
|
|
offset += limit;
|
|||
|
|
}
|
|||
|
|
const issues = allRows.filter(row => {
|
|||
|
|
const val = (row.Received || row.received || '').toLowerCase();
|
|||
|
|
return val === 'issues' || val === 'issue';
|
|||
|
|
}).map(row => ({
|
|||
|
|
id: row.Id,
|
|||
|
|
item: row.Item || row.Name || 'Unknown',
|
|||
|
|
orderNumber: row['Order Number'] || '',
|
|||
|
|
serialNumbers: row['Serial Numbers'] || '',
|
|||
|
|
sku: row.SKU || '',
|
|||
|
|
received: row.Received || row.received || '',
|
|||
|
|
trackingNumber: row['Tracking Number'] || '',
|
|||
|
|
notes: row.Notes || ''
|
|||
|
|
}));
|
|||
|
|
res.json({ issues, count: issues.length });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Issues fetch error:', error.message);
|
|||
|
|
res.status(500).json({ error: 'Failed to fetch issues', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get count of rows that have issues
|
|||
|
|
app.get('/needs-review-count', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (!config.apiToken) {
|
|||
|
|
return res.status(500).json({ error: 'NocoDB API token not configured' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('Fetching issues count...');
|
|||
|
|
|
|||
|
|
// Fetch all records with pagination
|
|||
|
|
let allRows = [];
|
|||
|
|
let offset = 0;
|
|||
|
|
const limit = 1000;
|
|||
|
|
let hasMore = true;
|
|||
|
|
|
|||
|
|
while (hasMore && offset < 10000) {
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
'xc-token': config.apiToken,
|
|||
|
|
'Accept': 'application/json'
|
|||
|
|
},
|
|||
|
|
params: {
|
|||
|
|
limit: limit,
|
|||
|
|
offset: offset
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const pageRows = response.data.list || [];
|
|||
|
|
allRows = allRows.concat(pageRows);
|
|||
|
|
hasMore = pageRows.length === limit;
|
|||
|
|
offset += limit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('API response received, total rows:', allRows.length);
|
|||
|
|
const issuesCount = allRows.filter(row => {
|
|||
|
|
const receivedValue = row.Received || row.received;
|
|||
|
|
return receivedValue === 'Issues' || receivedValue === 'Issue';
|
|||
|
|
}).length;
|
|||
|
|
|
|||
|
|
console.log('Found ' + issuesCount + ' items with issues');
|
|||
|
|
res.json({ count: issuesCount });
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error fetching issues count:', error.message);
|
|||
|
|
res.status(500).json({ error: 'Failed to fetch count', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/', (req, res) => {
|
|||
|
|
res.send(`<!DOCTYPE html>
|
|||
|
|
<html>
|
|||
|
|
<head>
|
|||
|
|
<title>NocoDB Photo Uploader</title>
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|||
|
|
<style>
|
|||
|
|
* {
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
}
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
min-height: 100vh;
|
|||
|
|
padding: 10px;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
body::before {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Ccircle cx='30' cy='30' r='4'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
.container {
|
|||
|
|
background: rgba(255, 255, 255, 0.95);
|
|||
|
|
backdrop-filter: blur(10px);
|
|||
|
|
-webkit-backdrop-filter: blur(10px);
|
|||
|
|
padding: 30px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(255,255,255,0.2);
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 800px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
border: 1px solid rgba(255,255,255,0.18);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
.container:hover {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 25px 50px rgba(0,0,0,0.15), 0 0 0 1px rgba(255,255,255,0.2);
|
|||
|
|
}
|
|||
|
|
h1 {
|
|||
|
|
font-size: clamp(1.8rem, 5vw, 2.5rem);
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
-webkit-background-clip: text;
|
|||
|
|
-webkit-text-fill-color: transparent;
|
|||
|
|
background-clip: text;
|
|||
|
|
font-weight: 700;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
.issues-counter {
|
|||
|
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
|||
|
|
color: white;
|
|||
|
|
padding: 20px;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
margin-bottom: 25px;
|
|||
|
|
text-align: center;
|
|||
|
|
box-shadow: 0 8px 25px rgba(255,107,107,0.3);
|
|||
|
|
border: none;
|
|||
|
|
transition: transform 0.2s ease;
|
|||
|
|
}
|
|||
|
|
.issues-counter:hover {
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
.issues-counter h3 {
|
|||
|
|
margin: 0 0 8px 0;
|
|||
|
|
color: white;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.issues-counter .count {
|
|||
|
|
font-size: 28px;
|
|||
|
|
font-weight: 800;
|
|||
|
|
color: white;
|
|||
|
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|||
|
|
}
|
|||
|
|
.config-info {
|
|||
|
|
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
|
|||
|
|
color: white;
|
|||
|
|
padding: 20px;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
margin-bottom: 30px;
|
|||
|
|
box-shadow: 0 8px 25px rgba(116,185,255,0.3);
|
|||
|
|
}
|
|||
|
|
.config-info h3 {
|
|||
|
|
color: white;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
}
|
|||
|
|
.config-info p {
|
|||
|
|
color: rgba(255,255,255,0.9);
|
|||
|
|
margin: 5px 0;
|
|||
|
|
}
|
|||
|
|
.form-group {
|
|||
|
|
margin-bottom: 25px;
|
|||
|
|
}
|
|||
|
|
.form-group label {
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #2d3748;
|
|||
|
|
font-size: 14px;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
}
|
|||
|
|
.search-container {
|
|||
|
|
position: relative;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
.search-input {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
border: 2px solid rgba(102, 126, 234, 0.1);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
font-size: 16px;
|
|||
|
|
background: rgba(255,255,255,0.8);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
backdrop-filter: blur(10px);
|
|||
|
|
}
|
|||
|
|
.search-input:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: #667eea;
|
|||
|
|
background: rgba(255,255,255,0.95);
|
|||
|
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
.search-results {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 100%;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
background: rgba(255,255,255,0.95);
|
|||
|
|
backdrop-filter: blur(10px);
|
|||
|
|
border: 2px solid rgba(102, 126, 234, 0.2);
|
|||
|
|
border-top: none;
|
|||
|
|
border-radius: 0 0 12px 12px;
|
|||
|
|
max-height: 250px;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
z-index: 100;
|
|||
|
|
display: none;
|
|||
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
|||
|
|
}
|
|||
|
|
.search-result-item {
|
|||
|
|
padding: 15px 20px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-bottom: 1px solid rgba(102, 126, 234, 0.1);
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
.search-result-item:hover {
|
|||
|
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
|||
|
|
transform: translateX(5px);
|
|||
|
|
}
|
|||
|
|
.search-result-item:last-child {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
.scan-buttons {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 10px;
|
|||
|
|
margin-top: 8px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
.scan-btn {
|
|||
|
|
flex: 1 1 140px;
|
|||
|
|
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
padding: 12px 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
box-shadow: 0 8px 20px rgba(0, 184, 148, 0.3);
|
|||
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
.scan-btn:hover {
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
box-shadow: 0 12px 24px rgba(0, 184, 148, 0.35);
|
|||
|
|
}
|
|||
|
|
.scan-btn:active {
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
.result-id {
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #667eea;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
.result-item {
|
|||
|
|
color: #2d3748;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin: 4px 0;
|
|||
|
|
}
|
|||
|
|
.result-status {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #718096;
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 0.5px;
|
|||
|
|
}
|
|||
|
|
input[type="text"] {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
border: 2px solid rgba(102, 126, 234, 0.1);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
font-size: 16px;
|
|||
|
|
background: rgba(255,255,255,0.8);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
backdrop-filter: blur(10px);
|
|||
|
|
}
|
|||
|
|
input[type="text"]:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: #667eea;
|
|||
|
|
background: rgba(255,255,255,0.95);
|
|||
|
|
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
}
|
|||
|
|
.drop-zone {
|
|||
|
|
border: 3px dashed rgba(102, 126, 234, 0.3);
|
|||
|
|
padding: 50px 30px;
|
|||
|
|
text-align: center;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
margin: 25px 0;
|
|||
|
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.drop-zone::before {
|
|||
|
|
content: '📸';
|
|||
|
|
font-size: 3rem;
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 15px;
|
|||
|
|
opacity: 0.7;
|
|||
|
|
}
|
|||
|
|
.drop-zone:hover {
|
|||
|
|
border-color: #667eea;
|
|||
|
|
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.2);
|
|||
|
|
}
|
|||
|
|
.drop-zone p:first-of-type {
|
|||
|
|
font-size: 1.2rem;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #2d3748;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
.upload-btn {
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
color: white;
|
|||
|
|
padding: 18px 35px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
width: 100%;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 18px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
|||
|
|
text-transform: uppercase;
|
|||
|
|
letter-spacing: 1px;
|
|||
|
|
}
|
|||
|
|
.upload-btn:hover {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 12px 35px rgba(102, 126, 234, 0.4);
|
|||
|
|
}
|
|||
|
|
.upload-btn:active {
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
#fileInput { display: none; }
|
|||
|
|
.preview-container { margin-top: 20px; }
|
|||
|
|
.preview-image {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 280px;
|
|||
|
|
object-fit: cover;
|
|||
|
|
margin: 15px 0 5px 0;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
border: none;
|
|||
|
|
box-shadow: 0 15px 35px rgba(0,0,0,0.1), 0 0 0 1px rgba(102, 126, 234, 0.1);
|
|||
|
|
display: block;
|
|||
|
|
transition: all 0.3s ease;
|
|||
|
|
}
|
|||
|
|
.preview-image:hover {
|
|||
|
|
transform: translateY(-5px) scale(1.02);
|
|||
|
|
box-shadow: 0 25px 50px rgba(0,0,0,0.15), 0 0 0 1px rgba(102, 126, 234, 0.2);
|
|||
|
|
}
|
|||
|
|
.file-preview {
|
|||
|
|
display: block;
|
|||
|
|
margin: 25px 0;
|
|||
|
|
position: relative;
|
|||
|
|
width: 100%;
|
|||
|
|
animation: fadeInUp 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes fadeInUp {
|
|||
|
|
from {
|
|||
|
|
opacity: 0;
|
|||
|
|
transform: translateY(30px);
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
opacity: 1;
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.file-name {
|
|||
|
|
font-size: 16px;
|
|||
|
|
color: #4a5568;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
word-wrap: break-word;
|
|||
|
|
font-weight: 600;
|
|||
|
|
padding: 15px 20px;
|
|||
|
|
background: rgba(102, 126, 234, 0.05);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
border-left: 4px solid #667eea;
|
|||
|
|
}
|
|||
|
|
.remove-btn {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 20px;
|
|||
|
|
right: 20px;
|
|||
|
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
width: 45px;
|
|||
|
|
height: 45px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 24px;
|
|||
|
|
box-shadow: 0 8px 20px rgba(255, 107, 107, 0.4);
|
|||
|
|
z-index: 10;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
.remove-btn:hover {
|
|||
|
|
transform: scale(1.1);
|
|||
|
|
box-shadow: 0 10px 25px rgba(255, 107, 107, 0.5);
|
|||
|
|
}
|
|||
|
|
.file-count {
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: white;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
font-size: 18px;
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
|
|||
|
|
border-radius: 25px;
|
|||
|
|
display: inline-block;
|
|||
|
|
box-shadow: 0 8px 20px rgba(0, 184, 148, 0.3);
|
|||
|
|
animation: slideIn 0.3s ease;
|
|||
|
|
}
|
|||
|
|
.status {
|
|||
|
|
margin-top: 25px;
|
|||
|
|
padding: 18px 25px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
backdrop-filter: blur(10px);
|
|||
|
|
animation: slideIn 0.3s ease;
|
|||
|
|
}
|
|||
|
|
.status.success {
|
|||
|
|
background: linear-gradient(135deg, #00b894 0%, #00a085 100%);
|
|||
|
|
color: white;
|
|||
|
|
box-shadow: 0 8px 25px rgba(0, 184, 148, 0.3);
|
|||
|
|
}
|
|||
|
|
.status.error {
|
|||
|
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
|||
|
|
color: white;
|
|||
|
|
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes slideIn {
|
|||
|
|
from {
|
|||
|
|
opacity: 0;
|
|||
|
|
transform: translateY(20px);
|
|||
|
|
}
|
|||
|
|
to {
|
|||
|
|
opacity: 1;
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Modal styles for item details */
|
|||
|
|
.modal {
|
|||
|
|
display: none;
|
|||
|
|
position: fixed;
|
|||
|
|
z-index: 1000;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
overflow: auto;
|
|||
|
|
background-color: rgba(0,0,0,0.4);
|
|||
|
|
}
|
|||
|
|
.modal-content {
|
|||
|
|
background-color: #fefefe;
|
|||
|
|
margin: 20px auto;
|
|||
|
|
padding: 0;
|
|||
|
|
border: 1px solid #888;
|
|||
|
|
width: calc(100% - 20px);
|
|||
|
|
max-width: 800px;
|
|||
|
|
max-height: calc(100vh - 40px);
|
|||
|
|
overflow: hidden;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|||
|
|
}
|
|||
|
|
.modal-header {
|
|||
|
|
padding: 20px;
|
|||
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|||
|
|
color: white;
|
|||
|
|
border-radius: 10px 10px 0 0;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
.modal-header h2 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 24px;
|
|||
|
|
}
|
|||
|
|
.close {
|
|||
|
|
color: white;
|
|||
|
|
position: absolute;
|
|||
|
|
right: 20px;
|
|||
|
|
top: 20px;
|
|||
|
|
font-size: 28px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
.close:hover { opacity: 0.8; }
|
|||
|
|
.modal-body {
|
|||
|
|
padding: 20px;
|
|||
|
|
max-height: calc(100vh - 140px);
|
|||
|
|
overflow-y: auto;
|
|||
|
|
-webkit-overflow-scrolling: touch;
|
|||
|
|
}
|
|||
|
|
.detail-row {
|
|||
|
|
display: flex;
|
|||
|
|
padding: 12px 0;
|
|||
|
|
border-bottom: 1px solid #eee;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
.detail-row:last-child {
|
|||
|
|
border-bottom: none;
|
|||
|
|
}
|
|||
|
|
.detail-label {
|
|||
|
|
font-weight: bold;
|
|||
|
|
width: 150px;
|
|||
|
|
color: #555;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
.detail-value {
|
|||
|
|
flex: 1;
|
|||
|
|
color: #333;
|
|||
|
|
word-wrap: break-word;
|
|||
|
|
word-break: break-word;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
.detail-value.empty {
|
|||
|
|
color: #999;
|
|||
|
|
font-style: italic;
|
|||
|
|
}
|
|||
|
|
.status-badge {
|
|||
|
|
display: inline-block;
|
|||
|
|
padding: 4px 12px;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
}
|
|||
|
|
.status-badge.closed {
|
|||
|
|
background: #d4edda;
|
|||
|
|
color: #155724;
|
|||
|
|
}
|
|||
|
|
.status-badge.issues {
|
|||
|
|
background: #f8d7da;
|
|||
|
|
color: #721c24;
|
|||
|
|
}
|
|||
|
|
.status-badge.open {
|
|||
|
|
background: #fff3cd;
|
|||
|
|
color: #856404;
|
|||
|
|
}
|
|||
|
|
.select-button {
|
|||
|
|
background: #4CAF50;
|
|||
|
|
color: white;
|
|||
|
|
padding: 10px 20px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 16px;
|
|||
|
|
margin-top: 20px;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
.select-button:hover {
|
|||
|
|
background: #45a049;
|
|||
|
|
}
|
|||
|
|
/* Scanner modal */
|
|||
|
|
.scanner-modal {
|
|||
|
|
display: none;
|
|||
|
|
position: fixed;
|
|||
|
|
z-index: 1200;
|
|||
|
|
left: 0; top: 0; right:0; bottom:0;
|
|||
|
|
background: rgba(0,0,0,0.6);
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
padding: 15px;
|
|||
|
|
}
|
|||
|
|
.scanner-content {
|
|||
|
|
background: #fff;
|
|||
|
|
max-width: 420px;
|
|||
|
|
width: 100%;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 18px;
|
|||
|
|
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
|
|||
|
|
}
|
|||
|
|
.scanner-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
.scanner-close {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
font-size: 24px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: #666;
|
|||
|
|
}
|
|||
|
|
.scanner-area {
|
|||
|
|
border: 2px dashed rgba(102,126,234,0.25);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
min-height: 280px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.scanner-hint {
|
|||
|
|
margin-top: 8px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #555;
|
|||
|
|
}
|
|||
|
|
/* Modern theme overrides (light, Jotform-inspired) */
|
|||
|
|
.modern-theme {
|
|||
|
|
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|||
|
|
background: radial-gradient(circle at 20% 20%, rgba(255, 138, 101, 0.15) 0, transparent 25%), radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.12) 0, transparent 22%), #f7f7fb;
|
|||
|
|
color: #1f2937;
|
|||
|
|
}
|
|||
|
|
.modern-theme body::before { display: none; }
|
|||
|
|
.modern-theme .container {
|
|||
|
|
background: #ffffff;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.1);
|
|||
|
|
}
|
|||
|
|
.modern-theme h1 {
|
|||
|
|
background: linear-gradient(135deg, #ff7a59, #10b981);
|
|||
|
|
}
|
|||
|
|
.modern-theme .issues-counter {
|
|||
|
|
background: linear-gradient(135deg, #ff7a59, #f97316);
|
|||
|
|
box-shadow: 0 10px 30px rgba(249, 115, 22, 0.25);
|
|||
|
|
}
|
|||
|
|
.modern-theme .search-input,
|
|||
|
|
.modern-theme input[type="text"],
|
|||
|
|
.modern-theme input[type="number"] {
|
|||
|
|
background: #fff;
|
|||
|
|
color: #111827;
|
|||
|
|
border-color: #e5e7eb;
|
|||
|
|
box-shadow: inset 0 1px 2px rgba(0,0,0,0.04);
|
|||
|
|
}
|
|||
|
|
.modern-theme .search-input:focus,
|
|||
|
|
.modern-theme input[type="text"]:focus,
|
|||
|
|
.modern-theme input[type="number"]:focus {
|
|||
|
|
border-color: #ff7a59;
|
|||
|
|
box-shadow: 0 0 0 4px rgba(255, 122, 89, 0.2);
|
|||
|
|
}
|
|||
|
|
.modern-theme .search-results {
|
|||
|
|
background: #ffffff;
|
|||
|
|
border-color: #e5e7eb;
|
|||
|
|
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
|
|||
|
|
}
|
|||
|
|
.modern-theme .search-result-item:hover {
|
|||
|
|
background: linear-gradient(135deg, rgba(255, 122, 89, 0.08), rgba(16, 185, 129, 0.08));
|
|||
|
|
}
|
|||
|
|
.modern-theme .drop-zone {
|
|||
|
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(255, 122, 89, 0.08));
|
|||
|
|
border-color: rgba(255, 122, 89, 0.35);
|
|||
|
|
}
|
|||
|
|
.modern-theme .upload-btn {
|
|||
|
|
background: linear-gradient(135deg, #ff7a59, #f97316);
|
|||
|
|
box-shadow: 0 10px 25px rgba(249, 115, 22, 0.25);
|
|||
|
|
}
|
|||
|
|
.modern-theme .scan-btn {
|
|||
|
|
background: linear-gradient(135deg, #10b981, #0ea271);
|
|||
|
|
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.25);
|
|||
|
|
}
|
|||
|
|
.modern-theme .file-name {
|
|||
|
|
background: rgba(255, 122, 89, 0.08);
|
|||
|
|
border-left-color: #ff7a59;
|
|||
|
|
color: #1f2937;
|
|||
|
|
}
|
|||
|
|
.modern-theme .status.success { background: linear-gradient(135deg, #10b981, #0ea271); }
|
|||
|
|
.modern-theme .status.error { background: linear-gradient(135deg, #f43f5e, #f97316); }
|
|||
|
|
|
|||
|
|
/* Mobile optimizations */
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
body {
|
|||
|
|
padding: 5px;
|
|||
|
|
}
|
|||
|
|
.container {
|
|||
|
|
padding: 20px;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
}
|
|||
|
|
.config-info {
|
|||
|
|
font-size: 14px;
|
|||
|
|
padding: 10px;
|
|||
|
|
}
|
|||
|
|
.preview-image {
|
|||
|
|
height: 200px;
|
|||
|
|
}
|
|||
|
|
input[type="text"], .search-input {
|
|||
|
|
padding: 12px;
|
|||
|
|
font-size: 16px; /* Prevents zoom on iOS */
|
|||
|
|
}
|
|||
|
|
.drop-zone {
|
|||
|
|
padding: 30px 15px;
|
|||
|
|
}
|
|||
|
|
.remove-btn {
|
|||
|
|
width: 40px;
|
|||
|
|
height: 40px;
|
|||
|
|
font-size: 22px;
|
|||
|
|
top: 20px;
|
|||
|
|
right: 10px;
|
|||
|
|
}
|
|||
|
|
.modal-content {
|
|||
|
|
width: calc(100% - 10px);
|
|||
|
|
margin: 5px auto;
|
|||
|
|
max-height: calc(100vh - 10px);
|
|||
|
|
}
|
|||
|
|
.modal-header {
|
|||
|
|
padding: 15px;
|
|||
|
|
}
|
|||
|
|
.modal-header h2 {
|
|||
|
|
font-size: 20px;
|
|||
|
|
}
|
|||
|
|
.modal-body {
|
|||
|
|
padding: 15px;
|
|||
|
|
max-height: calc(100vh - 100px);
|
|||
|
|
}
|
|||
|
|
.detail-label {
|
|||
|
|
width: 100%;
|
|||
|
|
margin-bottom: 5px;
|
|||
|
|
}
|
|||
|
|
.detail-value {
|
|||
|
|
width: 100%;
|
|||
|
|
padding-left: 0;
|
|||
|
|
}
|
|||
|
|
.detail-row {
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Tablet styles */
|
|||
|
|
@media (min-width: 769px) and (max-width: 1024px) {
|
|||
|
|
.container {
|
|||
|
|
max-width: 700px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Ensure inputs don't zoom on mobile */
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
input[type="text"],
|
|||
|
|
input[type="file"],
|
|||
|
|
select,
|
|||
|
|
textarea {
|
|||
|
|
font-size: 16px !important;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<script>
|
|||
|
|
(function() {
|
|||
|
|
const params = new URLSearchParams(window.location.search);
|
|||
|
|
if (params.get('theme') === 'modern') {
|
|||
|
|
document.body.classList.add('modern-theme');
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
</script>
|
|||
|
|
<div class="container">
|
|||
|
|
<h1>📷 NocoDB Photo Uploader</h1>
|
|||
|
|
|
|||
|
|
<div class="issues-counter">
|
|||
|
|
<h3>Items with Issues</h3>
|
|||
|
|
<div class="count" id="issuesCount">Loading...</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label>Search for Item:</label>
|
|||
|
|
<div class="search-container">
|
|||
|
|
<input type="text" id="searchInput" class="search-input" placeholder="Type item name to search..." />
|
|||
|
|
<div id="searchResults" class="search-results"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="scan-buttons">
|
|||
|
|
<button class="scan-btn" onclick="openScanner('search')">📷 Scan for Search</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label>Row ID:</label>
|
|||
|
|
<input type="text" id="rowId" placeholder="Enter row ID or search above" />
|
|||
|
|
</div>
|
|||
|
|
<div id="nocoLinkBox" style="display:none; margin-bottom:15px;">
|
|||
|
|
<a id="nocoLink" href="#" target="_blank" rel="noopener" style="color:#2563eb;font-weight:700;">Open in NocoDB</a>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label>Item Details:</label>
|
|||
|
|
<div id="editForm"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="drop-zone" onclick="document.getElementById('fileInput').click()">
|
|||
|
|
<p>📁 Click to add photos</p>
|
|||
|
|
<p style="font-size: 14px; color: #888;">Each click adds more photos - they accumulate until you upload</p>
|
|||
|
|
<input type="file" id="fileInput" multiple accept="image/*" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="previewContainer"></div>
|
|||
|
|
<button class="upload-btn" onclick="uploadPhotos()">Upload Photos</button>
|
|||
|
|
<div id="status"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Item Details Modal -->
|
|||
|
|
<div id="itemModal" class="modal">
|
|||
|
|
<div class="modal-content">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h2 id="modalTitle">Item Details</h2>
|
|||
|
|
<span class="close" onclick="closeModal()">×</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body" id="modalBody">
|
|||
|
|
<!-- Details will be inserted here -->
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- Scanner Modal -->
|
|||
|
|
<div id="scannerModal" class="scanner-modal">
|
|||
|
|
<div class="scanner-content">
|
|||
|
|
<div class="scanner-header">
|
|||
|
|
<h3 id="scannerTitle">Scan</h3>
|
|||
|
|
<button class="scanner-close" onclick="closeScanner()">×</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="scannerArea" class="scanner-area"></div>
|
|||
|
|
<div class="scanner-hint">Align the barcode in the frame. Scans close automatically on success.</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
let selectedFiles = [];
|
|||
|
|
let scannerInstance = null;
|
|||
|
|
let scannerTarget = null;
|
|||
|
|
let detectorStream = null;
|
|||
|
|
let detectorInterval = null;
|
|||
|
|
let detectorRunning = false;
|
|||
|
|
const nocoLinkBase = '${(config.ncodbPublicUrl || '').endsWith('/') ? config.ncodbPublicUrl.slice(0, -1) : (config.ncodbPublicUrl || '')}/#/nc/${config.baseId}/${config.tableId}';
|
|||
|
|
const fieldDefs = [
|
|||
|
|
{ key: 'Id', label: 'ID', readonly: true },
|
|||
|
|
{ key: 'Item', label: 'Item' },
|
|||
|
|
{ key: 'Order Number', label: 'Order Number' },
|
|||
|
|
{ key: 'Serial Numbers', label: 'Serial Numbers' },
|
|||
|
|
{ key: 'SKU', label: 'SKU' },
|
|||
|
|
{ key: 'Vendor', label: 'Vendor' },
|
|||
|
|
{ key: 'Date', label: 'Date' },
|
|||
|
|
{ key: 'Tracking Number', label: 'Tracking Number' },
|
|||
|
|
{ key: 'Received', label: 'Status/Received' },
|
|||
|
|
{ key: 'QTY', label: 'Quantity' },
|
|||
|
|
{ key: 'Price Per Item', label: 'Price Per Item' },
|
|||
|
|
{ key: 'Tax', label: 'Tax' },
|
|||
|
|
{ key: 'Total', label: 'Total' },
|
|||
|
|
{ key: 'Name', label: 'Name' }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Prefill from query params (rowId/item) for deep links
|
|||
|
|
(function prefillFromQuery() {
|
|||
|
|
try {
|
|||
|
|
const params = new URLSearchParams(window.location.search);
|
|||
|
|
const rowId = params.get('rowId') || params.get('id');
|
|||
|
|
const itemName = params.get('item');
|
|||
|
|
if (rowId) {
|
|||
|
|
selectItem(rowId, itemName || '');
|
|||
|
|
showItemDetails(rowId);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('Prefill error:', e);
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// Load issues count
|
|||
|
|
loadIssuesCount();
|
|||
|
|
renderEditForm({});
|
|||
|
|
|
|||
|
|
// Search functionality
|
|||
|
|
let searchTimeout;
|
|||
|
|
document.getElementById('searchInput').addEventListener('input', function(e) {
|
|||
|
|
clearTimeout(searchTimeout);
|
|||
|
|
const query = e.target.value.trim();
|
|||
|
|
|
|||
|
|
if (query.length < 2) {
|
|||
|
|
document.getElementById('searchResults').style.display = 'none';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
searchTimeout = setTimeout(() => searchItems(query), 300);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Hide search results when clicking outside
|
|||
|
|
document.addEventListener('click', function(e) {
|
|||
|
|
if (!e.target.closest('.search-container')) {
|
|||
|
|
document.getElementById('searchResults').style.display = 'none';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
async function searchItems(query) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/search-records?q=' + encodeURIComponent(query));
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
const resultsDiv = document.getElementById('searchResults');
|
|||
|
|
resultsDiv.innerHTML = '';
|
|||
|
|
|
|||
|
|
if (data.results && data.results.length > 0) {
|
|||
|
|
data.results.forEach(item => {
|
|||
|
|
const itemDiv = document.createElement('div');
|
|||
|
|
itemDiv.className = 'search-result-item';
|
|||
|
|
|
|||
|
|
const resultId = document.createElement('div');
|
|||
|
|
resultId.className = 'result-id';
|
|||
|
|
resultId.textContent = 'ID: ' + item.id;
|
|||
|
|
|
|||
|
|
const resultItem = document.createElement('div');
|
|||
|
|
resultItem.className = 'result-item';
|
|||
|
|
resultItem.textContent = item.item;
|
|||
|
|
|
|||
|
|
const resultStatus = document.createElement('div');
|
|||
|
|
resultStatus.className = 'result-status';
|
|||
|
|
resultStatus.textContent = 'Status: ' + item.received;
|
|||
|
|
|
|||
|
|
itemDiv.appendChild(resultId);
|
|||
|
|
itemDiv.appendChild(resultItem);
|
|||
|
|
itemDiv.appendChild(resultStatus);
|
|||
|
|
|
|||
|
|
itemDiv.onclick = () => {
|
|||
|
|
selectItem(item.id, item.item);
|
|||
|
|
showItemDetails(item.id);
|
|||
|
|
};
|
|||
|
|
resultsDiv.appendChild(itemDiv);
|
|||
|
|
});
|
|||
|
|
resultsDiv.style.display = 'block';
|
|||
|
|
} else {
|
|||
|
|
resultsDiv.innerHTML = '<div class="search-result-item">No results found</div>';
|
|||
|
|
resultsDiv.style.display = 'block';
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Search error:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectItem(id, itemName) {
|
|||
|
|
document.getElementById('rowId').value = id;
|
|||
|
|
document.getElementById('searchInput').value = itemName;
|
|||
|
|
document.getElementById('searchResults').style.display = 'none';
|
|||
|
|
updateNocoLink(id);
|
|||
|
|
// Clear search box so next search can start fresh
|
|||
|
|
document.getElementById('searchInput').value = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderEditForm(item = {}) {
|
|||
|
|
const container = document.getElementById('editForm');
|
|||
|
|
if (!container) return;
|
|||
|
|
container.innerHTML = '';
|
|||
|
|
fieldDefs.forEach(def => {
|
|||
|
|
const wrap = document.createElement('div');
|
|||
|
|
wrap.style.marginBottom = '15px';
|
|||
|
|
const label = document.createElement('label');
|
|||
|
|
label.textContent = def.label;
|
|||
|
|
label.style.display = 'block';
|
|||
|
|
label.style.marginBottom = '6px';
|
|||
|
|
label.style.fontWeight = '600';
|
|||
|
|
|
|||
|
|
const input = document.createElement('input');
|
|||
|
|
input.id = 'edit-' + def.key;
|
|||
|
|
input.type = (def.key === 'Price Per Item' || def.key === 'Tax' || def.key === 'Total' || def.key === 'QTY') ? 'number' : 'text';
|
|||
|
|
input.value = item[def.key] !== undefined && item[def.key] !== null ? item[def.key] : '';
|
|||
|
|
input.style.width = '100%';
|
|||
|
|
input.style.padding = '12px';
|
|||
|
|
input.style.borderRadius = '10px';
|
|||
|
|
input.style.border = '2px solid rgba(102, 126, 234, 0.1)';
|
|||
|
|
input.style.background = 'rgba(255,255,255,0.8)';
|
|||
|
|
if (def.readonly) {
|
|||
|
|
input.readOnly = true;
|
|||
|
|
input.style.opacity = '0.7';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
wrap.appendChild(label);
|
|||
|
|
wrap.appendChild(input);
|
|||
|
|
|
|||
|
|
// Inline scan buttons for specific fields
|
|||
|
|
if (def.key === 'Serial Numbers' || def.key === 'Tracking Number') {
|
|||
|
|
const btnRow = document.createElement('div');
|
|||
|
|
btnRow.className = 'scan-buttons';
|
|||
|
|
btnRow.style.marginTop = '8px';
|
|||
|
|
const btn = document.createElement('button');
|
|||
|
|
btn.className = 'scan-btn';
|
|||
|
|
btn.type = 'button';
|
|||
|
|
btn.textContent = def.key === 'Serial Numbers' ? '🔍 Scan Serial' : '🧭 Scan Tracking';
|
|||
|
|
btn.onclick = () => openScanner(def.key === 'Serial Numbers' ? 'serial' : 'tracking');
|
|||
|
|
btnRow.appendChild(btn);
|
|||
|
|
wrap.appendChild(btnRow);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
container.appendChild(wrap);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function collectEditValues() {
|
|||
|
|
const values = {};
|
|||
|
|
fieldDefs.forEach(def => {
|
|||
|
|
if (def.readonly) return;
|
|||
|
|
const el = document.getElementById('edit-' + def.key);
|
|||
|
|
if (!el) return;
|
|||
|
|
values[def.key] = el.value ?? '';
|
|||
|
|
});
|
|||
|
|
return values;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function showItemDetails(id) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/item-details/' + id);
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (data.success && data.item) {
|
|||
|
|
renderEditForm(data.item);
|
|||
|
|
const modal = document.getElementById('itemModal');
|
|||
|
|
const modalBody = document.getElementById('modalBody');
|
|||
|
|
const modalTitle = document.getElementById('modalTitle');
|
|||
|
|
|
|||
|
|
// Set title
|
|||
|
|
modalTitle.textContent = data.item.Item || data.item.Name || 'Item Details';
|
|||
|
|
|
|||
|
|
// Build details HTML
|
|||
|
|
let detailsHTML = '';
|
|||
|
|
|
|||
|
|
// Define the order and labels for fields
|
|||
|
|
const fieldOrder = [
|
|||
|
|
{ key: 'Id', label: 'ID' },
|
|||
|
|
{ key: 'Item', label: 'Item' },
|
|||
|
|
{ key: 'Name', label: 'Name' },
|
|||
|
|
{ key: 'Received', label: 'Status' },
|
|||
|
|
{ key: 'Order Number', label: 'Order Number' },
|
|||
|
|
{ key: 'SKU', label: 'SKU' },
|
|||
|
|
{ key: 'Serial Numbers', label: 'Serial Numbers' },
|
|||
|
|
{ key: 'QTY', label: 'Quantity' },
|
|||
|
|
{ key: 'Price Per Item', label: 'Price Per Item' },
|
|||
|
|
{ key: 'Tax', label: 'Tax' },
|
|||
|
|
{ key: 'Total', label: 'Total' },
|
|||
|
|
{ key: 'Vendor', label: 'Vendor' },
|
|||
|
|
{ key: 'Date', label: 'Date' },
|
|||
|
|
{ key: 'Tracking Number', label: 'Tracking Number' },
|
|||
|
|
{ key: 'CreatedAt', label: 'Created' },
|
|||
|
|
{ key: 'UpdatedAt', label: 'Updated' }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
fieldOrder.forEach(field => {
|
|||
|
|
const value = data.item[field.key];
|
|||
|
|
if (field.key === 'CreatedAt' || field.key === 'UpdatedAt') {
|
|||
|
|
// Skip timestamp fields or format them nicely
|
|||
|
|
if (value) {
|
|||
|
|
const date = new Date(value);
|
|||
|
|
detailsHTML += '<div class="detail-row">';
|
|||
|
|
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
|
|||
|
|
detailsHTML += '<div class="detail-value">' + date.toLocaleString() + '</div>';
|
|||
|
|
detailsHTML += '</div>';
|
|||
|
|
}
|
|||
|
|
} else if (field.key === 'Received') {
|
|||
|
|
// Special formatting for status
|
|||
|
|
detailsHTML += '<div class="detail-row">';
|
|||
|
|
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
|
|||
|
|
detailsHTML += '<div class="detail-value">';
|
|||
|
|
const status = value || 'Unknown';
|
|||
|
|
const statusClass = status.toLowerCase() === 'closed' ? 'closed' :
|
|||
|
|
status.toLowerCase() === 'issues' ? 'issues' : 'open';
|
|||
|
|
detailsHTML += '<span class="status-badge ' + statusClass + '">' + status + '</span>';
|
|||
|
|
detailsHTML += '</div></div>';
|
|||
|
|
} else if (value !== null && value !== undefined && value !== '') {
|
|||
|
|
detailsHTML += '<div class="detail-row">';
|
|||
|
|
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
|
|||
|
|
detailsHTML += '<div class="detail-value">' + value + '</div>';
|
|||
|
|
detailsHTML += '</div>';
|
|||
|
|
} else if (field.key === 'Item' || field.key === 'Name' || field.key === 'Order Number') {
|
|||
|
|
// Show important fields even if empty
|
|||
|
|
detailsHTML += '<div class="detail-row">';
|
|||
|
|
detailsHTML += '<div class="detail-label">' + field.label + ':</div>';
|
|||
|
|
detailsHTML += '<div class="detail-value empty">Not provided</div>';
|
|||
|
|
detailsHTML += '</div>';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
modalBody.innerHTML = detailsHTML;
|
|||
|
|
modal.style.display = 'none'; // keep hidden
|
|||
|
|
updateNocoLink(id);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error fetching item details:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function selectAndClose(id, itemName) {
|
|||
|
|
selectItem(id, itemName);
|
|||
|
|
closeModal();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeModal() {
|
|||
|
|
document.getElementById('itemModal').style.display = 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateNocoLink(id) {
|
|||
|
|
const box = document.getElementById('nocoLinkBox');
|
|||
|
|
const link = document.getElementById('nocoLink');
|
|||
|
|
if (!box || !link) return;
|
|||
|
|
if (!id) {
|
|||
|
|
box.style.display = 'none';
|
|||
|
|
link.href = '#';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
link.href = nocoLinkBase + '?rowId=' + encodeURIComponent(id);
|
|||
|
|
box.style.display = 'block';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Close modal when clicking outside
|
|||
|
|
window.onclick = function(event) {
|
|||
|
|
const modal = document.getElementById('itemModal');
|
|||
|
|
if (event.target == modal) {
|
|||
|
|
modal.style.display = 'none';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadIssuesCount() {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/needs-review-count');
|
|||
|
|
const data = await response.json();
|
|||
|
|
document.getElementById('issuesCount').textContent = data.count;
|
|||
|
|
} catch (error) {
|
|||
|
|
document.getElementById('issuesCount').textContent = 'Error';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('fileInput').addEventListener('change', function(e) {
|
|||
|
|
const newFiles = Array.from(e.target.files);
|
|||
|
|
selectedFiles = [...selectedFiles, ...newFiles];
|
|||
|
|
this.value = '';
|
|||
|
|
displayPreviews();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function displayPreviews() {
|
|||
|
|
const container = document.getElementById('previewContainer');
|
|||
|
|
container.innerHTML = '';
|
|||
|
|
|
|||
|
|
if (selectedFiles.length > 0) {
|
|||
|
|
const fileCount = document.createElement('div');
|
|||
|
|
fileCount.className = 'file-count';
|
|||
|
|
fileCount.textContent = selectedFiles.length + ' file(s) selected';
|
|||
|
|
container.appendChild(fileCount);
|
|||
|
|
|
|||
|
|
selectedFiles.forEach((file, index) => {
|
|||
|
|
const fileDiv = document.createElement('div');
|
|||
|
|
fileDiv.className = 'file-preview';
|
|||
|
|
|
|||
|
|
if (file.type.startsWith('image/')) {
|
|||
|
|
const img = document.createElement('img');
|
|||
|
|
img.className = 'preview-image';
|
|||
|
|
img.src = URL.createObjectURL(file);
|
|||
|
|
fileDiv.appendChild(img);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fileName = document.createElement('div');
|
|||
|
|
fileName.className = 'file-name';
|
|||
|
|
fileName.textContent = file.name;
|
|||
|
|
fileDiv.appendChild(fileName);
|
|||
|
|
|
|||
|
|
const removeBtn = document.createElement('button');
|
|||
|
|
removeBtn.className = 'remove-btn';
|
|||
|
|
removeBtn.textContent = '×';
|
|||
|
|
removeBtn.onclick = () => removeFile(index);
|
|||
|
|
fileDiv.appendChild(removeBtn);
|
|||
|
|
|
|||
|
|
container.appendChild(fileDiv);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeFile(index) {
|
|||
|
|
selectedFiles.splice(index, 1);
|
|||
|
|
displayPreviews();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Scanner handling ---
|
|||
|
|
function ensureScannerScript() {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
if (window.Html5Qrcode) return resolve();
|
|||
|
|
const script = document.createElement('script');
|
|||
|
|
script.src = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
|
|||
|
|
script.async = true;
|
|||
|
|
script.onload = () => resolve();
|
|||
|
|
script.onerror = () => reject(new Error('Failed to load scanner lib'));
|
|||
|
|
document.head.appendChild(script);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function startBarcodeDetector(target) {
|
|||
|
|
if (!('BarcodeDetector' in window)) return false;
|
|||
|
|
const formats = ['qr_code','code_128','code_39','codabar','ean_13','ean_8','upc_a','upc_e','itf','data_matrix','pdf417'];
|
|||
|
|
let detector;
|
|||
|
|
try {
|
|||
|
|
detector = new BarcodeDetector({ formats });
|
|||
|
|
} catch (e) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const area = document.getElementById('scannerArea');
|
|||
|
|
area.innerHTML = '';
|
|||
|
|
const video = document.createElement('video');
|
|||
|
|
video.setAttribute('playsinline', 'true');
|
|||
|
|
video.style.width = '100%';
|
|||
|
|
video.style.borderRadius = '8px';
|
|||
|
|
area.appendChild(video);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
detectorStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn('Camera permission error:', e);
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
video.srcObject = detectorStream;
|
|||
|
|
await video.play();
|
|||
|
|
detectorRunning = true;
|
|||
|
|
|
|||
|
|
const canvas = document.createElement('canvas');
|
|||
|
|
const ctx = canvas.getContext('2d');
|
|||
|
|
|
|||
|
|
const scanFrame = async () => {
|
|||
|
|
if (!detectorRunning) return;
|
|||
|
|
if (video.readyState === video.HAVE_ENOUGH_DATA) {
|
|||
|
|
canvas.width = video.videoWidth;
|
|||
|
|
canvas.height = video.videoHeight;
|
|||
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|||
|
|
try {
|
|||
|
|
const barcodes = await detector.detect(canvas);
|
|||
|
|
if (barcodes && barcodes.length > 0) {
|
|||
|
|
const text = barcodes[0].rawValue || barcodes[0].rawdata || '';
|
|||
|
|
if (text) {
|
|||
|
|
onScanSuccess(text);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// ignore detection errors per frame
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
detectorInterval = requestAnimationFrame(scanFrame);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
detectorInterval = requestAnimationFrame(scanFrame);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopBarcodeDetector() {
|
|||
|
|
detectorRunning = false;
|
|||
|
|
if (detectorInterval) {
|
|||
|
|
cancelAnimationFrame(detectorInterval);
|
|||
|
|
detectorInterval = null;
|
|||
|
|
}
|
|||
|
|
if (detectorStream) {
|
|||
|
|
detectorStream.getTracks().forEach(t => t.stop());
|
|||
|
|
detectorStream = null;
|
|||
|
|
}
|
|||
|
|
const area = document.getElementById('scannerArea');
|
|||
|
|
if (area) area.innerHTML = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openScanner(target) {
|
|||
|
|
scannerTarget = target;
|
|||
|
|
document.getElementById('scannerModal').style.display = 'flex';
|
|||
|
|
document.getElementById('scannerTitle').textContent =
|
|||
|
|
target === 'serial' ? 'Scan Serial' : target === 'tracking' ? 'Scan Tracking' : 'Scan for Search';
|
|||
|
|
try {
|
|||
|
|
// Try native detector first
|
|||
|
|
const detectorStarted = await startBarcodeDetector(target);
|
|||
|
|
if (detectorStarted) return;
|
|||
|
|
|
|||
|
|
// Fallback to html5-qrcode
|
|||
|
|
await ensureScannerScript();
|
|||
|
|
if (scannerInstance) {
|
|||
|
|
await scannerInstance.stop();
|
|||
|
|
}
|
|||
|
|
scannerInstance = new Html5Qrcode('scannerArea');
|
|||
|
|
await scannerInstance.start(
|
|||
|
|
{ facingMode: 'environment' },
|
|||
|
|
{ fps: 10, qrbox: { width: 260, height: 260 } },
|
|||
|
|
onScanSuccess,
|
|||
|
|
onScanError
|
|||
|
|
);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Scanner error:', err);
|
|||
|
|
alert('Camera not available: ' + err.message);
|
|||
|
|
closeScanner();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function closeScanner() {
|
|||
|
|
document.getElementById('scannerModal').style.display = 'none';
|
|||
|
|
stopBarcodeDetector();
|
|||
|
|
if (scannerInstance) {
|
|||
|
|
try { await scannerInstance.stop(); } catch (e) {}
|
|||
|
|
try { scannerInstance.clear(); } catch (e) {}
|
|||
|
|
scannerInstance = null;
|
|||
|
|
}
|
|||
|
|
document.getElementById('scannerArea').innerHTML = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onScanSuccess(decodedText) {
|
|||
|
|
if (!decodedText) return;
|
|||
|
|
const trimmed = decodedText.trim();
|
|||
|
|
if (scannerTarget === 'serial') {
|
|||
|
|
const serialInput = document.getElementById('edit-Serial Numbers');
|
|||
|
|
if (serialInput) {
|
|||
|
|
if (serialInput.value) {
|
|||
|
|
serialInput.value = serialInput.value + ', ' + trimmed;
|
|||
|
|
} else {
|
|||
|
|
serialInput.value = trimmed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (scannerTarget === 'tracking') {
|
|||
|
|
const trackingInput = document.getElementById('edit-Tracking Number');
|
|||
|
|
if (trackingInput) {
|
|||
|
|
trackingInput.value = trimmed;
|
|||
|
|
}
|
|||
|
|
const searchInput = document.getElementById('searchInput');
|
|||
|
|
if (searchInput) {
|
|||
|
|
searchInput.value = trimmed;
|
|||
|
|
searchItems(trimmed);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
const searchInput = document.getElementById('searchInput');
|
|||
|
|
searchInput.value = trimmed;
|
|||
|
|
searchItems(trimmed);
|
|||
|
|
}
|
|||
|
|
closeScanner();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onScanError(err) {
|
|||
|
|
// Quietly ignore; library calls this frequently during scan
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function uploadPhotos() {
|
|||
|
|
const rowId = document.getElementById('rowId').value;
|
|||
|
|
const fieldUpdates = collectEditValues();
|
|||
|
|
const hasFields = Object.keys(fieldUpdates).length > 0;
|
|||
|
|
if (!rowId || (!selectedFiles.length && !hasFields)) {
|
|||
|
|
document.getElementById('status').innerHTML = '<div class="status error">Please enter row ID and select files or fields to update</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('rowId', rowId);
|
|||
|
|
formData.append('fields', JSON.stringify(fieldUpdates));
|
|||
|
|
selectedFiles.forEach(file => formData.append('photos', file));
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('/upload', { method: 'POST', body: formData });
|
|||
|
|
const result = await response.json();
|
|||
|
|
if (response.ok) {
|
|||
|
|
document.getElementById('status').innerHTML = '<div class="status success">Upload successful!</div>';
|
|||
|
|
loadIssuesCount(); // Refresh count
|
|||
|
|
|
|||
|
|
// Clear all fields and files
|
|||
|
|
selectedFiles = [];
|
|||
|
|
document.getElementById('fileInput').value = '';
|
|||
|
|
document.getElementById('rowId').value = '';
|
|||
|
|
document.getElementById('searchInput').value = ''; // Clear search field
|
|||
|
|
document.getElementById('searchResults').style.display = 'none'; // Hide search results
|
|||
|
|
document.getElementById('previewContainer').innerHTML = '';
|
|||
|
|
renderEditForm({}); // Clear form
|
|||
|
|
} else {
|
|||
|
|
throw new Error(result.error);
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
document.getElementById('status').innerHTML = '<div class="status error">Error: ' + error.message + '</div>';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Proxy NocoDB images
|
|||
|
|
app.get('/noco-image/*', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const imagePath = req.params[0];
|
|||
|
|
if (!imagePath) return res.status(400).json({ error: 'Missing path' });
|
|||
|
|
|
|||
|
|
const imageUrl = config.ncodbUrl + '/' + imagePath;
|
|||
|
|
const response = await axios.get(imageUrl, {
|
|||
|
|
headers: { 'xc-token': config.apiToken },
|
|||
|
|
responseType: 'stream',
|
|||
|
|
timeout: 15000
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.set('Content-Type', response.headers['content-type'] || 'image/jpeg');
|
|||
|
|
res.set('Cache-Control', 'public, max-age=86400');
|
|||
|
|
response.data.pipe(res);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Image proxy error:', error.message);
|
|||
|
|
res.status(404).json({ error: 'Image not found' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Create new record
|
|||
|
|
app.post('/create', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const fields = req.body || {};
|
|||
|
|
if (!fields.Item) {
|
|||
|
|
return res.status(400).json({ error: 'Item name is required' });
|
|||
|
|
}
|
|||
|
|
// Only send known fields, skip junk
|
|||
|
|
const allowed = ['Item', 'Order Number', 'Serial Numbers', 'SKU', 'Received',
|
|||
|
|
'Price Per Item', 'Tax', 'Total', 'QTY', 'Notes',
|
|||
|
|
'Tracking Number', 'Source', 'Platform', 'Category'];
|
|||
|
|
const record = {};
|
|||
|
|
for (const key of allowed) {
|
|||
|
|
if (fields[key] !== undefined && fields[key] !== null && fields[key] !== '') {
|
|||
|
|
record[key] = fields[key];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Default status
|
|||
|
|
if (!record.Received) record.Received = 'Pending';
|
|||
|
|
|
|||
|
|
const createResp = await axios.post(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
record,
|
|||
|
|
{ headers: { 'Content-Type': 'application/json', 'xc-token': config.apiToken } }
|
|||
|
|
);
|
|||
|
|
const newId = createResp.data?.Id || createResp.data?.id;
|
|||
|
|
console.log('Created new record:', newId, record.Item);
|
|||
|
|
res.json({ success: true, id: newId, item: createResp.data });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Create error:', error.response?.data || error.message);
|
|||
|
|
res.status(500).json({ error: 'Create failed', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Update item fields
|
|||
|
|
app.patch('/item/:id', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const rowId = req.params.id;
|
|||
|
|
const fields = req.body;
|
|||
|
|
if (!rowId || !fields || Object.keys(fields).length === 0) {
|
|||
|
|
return res.status(400).json({ error: 'Missing rowId or fields' });
|
|||
|
|
}
|
|||
|
|
await axios.patch(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
|
|||
|
|
fields,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'xc-token': config.apiToken
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
res.json({ success: true });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Update error:', error.response?.data || error.message);
|
|||
|
|
res.status(500).json({ error: 'Update failed', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/duplicate/:id', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const rowId = req.params.id;
|
|||
|
|
// Fetch original record
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
|
|||
|
|
{ headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' } }
|
|||
|
|
);
|
|||
|
|
const original = response.data;
|
|||
|
|
// Strip fields that shouldn't be duplicated
|
|||
|
|
const skip = ['Id', 'id', 'CreatedAt', 'UpdatedAt', 'nc_', 'photos', 'Photos', 'row_id',
|
|||
|
|
'Print', 'Print SKU', 'send to phone'];
|
|||
|
|
const newRecord = {};
|
|||
|
|
for (const [key, value] of Object.entries(original)) {
|
|||
|
|
if (skip.some(s => key === s || key.startsWith(s))) continue;
|
|||
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) continue; // skip webhook buttons etc
|
|||
|
|
if (value !== null && value !== undefined) {
|
|||
|
|
newRecord[key] = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Create new record
|
|||
|
|
const createResp = await axios.post(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
newRecord,
|
|||
|
|
{ headers: { 'Content-Type': 'application/json', 'xc-token': config.apiToken } }
|
|||
|
|
);
|
|||
|
|
console.log('Duplicated record', rowId, '-> new ID:', createResp.data?.Id || createResp.data?.id);
|
|||
|
|
res.json({ success: true, newId: createResp.data?.Id || createResp.data?.id, item: createResp.data });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Duplicate error:', error.response?.data || error.message);
|
|||
|
|
res.status(500).json({ error: 'Duplicate failed', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/upload', upload.array('photos'), async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { rowId } = req.body;
|
|||
|
|
const files = req.files || [];
|
|||
|
|
const rawFields = req.body.fields;
|
|||
|
|
let fieldUpdates = {};
|
|||
|
|
if (rawFields) {
|
|||
|
|
try { fieldUpdates = JSON.parse(rawFields); } catch (e) { fieldUpdates = {}; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!rowId || !config.apiToken) {
|
|||
|
|
return res.status(400).json({ error: 'Missing required data' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasFiles = files.length > 0;
|
|||
|
|
const hasFields = fieldUpdates && Object.keys(fieldUpdates).length > 0;
|
|||
|
|
if (!hasFiles && !hasFields) {
|
|||
|
|
return res.status(400).json({ error: 'No files or fields to update' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const uploadedFiles = [];
|
|||
|
|
|
|||
|
|
for (let file of files) {
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', file.buffer, {
|
|||
|
|
filename: file.originalname,
|
|||
|
|
contentType: file.mimetype
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const uploadResponse = await axios.post(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/storage/upload',
|
|||
|
|
formData,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
...formData.getHeaders(),
|
|||
|
|
'xc-token': config.apiToken
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
uploadedFiles.push(...uploadResponse.data);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const updateData = { ...fieldUpdates };
|
|||
|
|
if (hasFiles) {
|
|||
|
|
updateData[config.columnName] = uploadedFiles;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await axios.patch(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
|
|||
|
|
updateData,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'xc-token': config.apiToken
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
res.json({ success: true, message: 'Upload successful' });
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
const status = error.response?.status;
|
|||
|
|
const data = error.response?.data;
|
|||
|
|
const details = typeof data === 'string' ? data.slice(0, 500) : data || error.message;
|
|||
|
|
console.error('Upload error:', status || '', details);
|
|||
|
|
res.status(500).json({ error: 'Upload failed', status, details });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Recent items — sorted by last updated
|
|||
|
|
app.get('/recent', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (!config.apiToken) {
|
|||
|
|
return res.status(500).json({ error: 'NocoDB API token not configured' });
|
|||
|
|
}
|
|||
|
|
const limit = Math.min(parseInt(req.query.limit) || 30, 100);
|
|||
|
|
const response = await axios.get(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId,
|
|||
|
|
{
|
|||
|
|
headers: { 'xc-token': config.apiToken, 'Accept': 'application/json' },
|
|||
|
|
params: { limit, sort: '-UpdatedAt' }
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
const items = (response.data.list || []).map(row => ({
|
|||
|
|
id: row.Id,
|
|||
|
|
item: row.Item || row.Name || 'Unknown',
|
|||
|
|
received: row.Received || '',
|
|||
|
|
updatedAt: row.UpdatedAt || ''
|
|||
|
|
}));
|
|||
|
|
res.json({ items, count: items.length });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Recent fetch error:', error.message);
|
|||
|
|
res.status(500).json({ error: 'Failed to fetch recent items', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Upload from Immich — server-to-server, no browser involved
|
|||
|
|
app.post('/upload-from-immich', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { rowId, assetIds, deleteAfter } = req.body;
|
|||
|
|
const immichUrl = process.env.IMMICH_URL;
|
|||
|
|
const immichApiKey = process.env.IMMICH_API_KEY;
|
|||
|
|
if (!rowId || !assetIds?.length) {
|
|||
|
|
return res.status(400).json({ error: 'Missing rowId or assetIds' });
|
|||
|
|
}
|
|||
|
|
if (!immichUrl || !immichApiKey) {
|
|||
|
|
return res.status(500).json({ error: 'Immich not configured on server' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const uploaded = [];
|
|||
|
|
for (const assetId of assetIds) {
|
|||
|
|
try {
|
|||
|
|
// Download original from Immich
|
|||
|
|
const imgRes = await axios.get(`${immichUrl}/api/assets/${assetId}/original`, {
|
|||
|
|
headers: { 'x-api-key': immichApiKey },
|
|||
|
|
responseType: 'arraybuffer'
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get filename from Immich
|
|||
|
|
const assetInfo = await axios.get(`${immichUrl}/api/assets/${assetId}`, {
|
|||
|
|
headers: { 'x-api-key': immichApiKey }
|
|||
|
|
});
|
|||
|
|
const filename = assetInfo.data.originalFileName || `immich-${assetId}.jpg`;
|
|||
|
|
const mimetype = assetInfo.data.originalMimeType || 'image/jpeg';
|
|||
|
|
|
|||
|
|
// Upload to NocoDB storage
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', Buffer.from(imgRes.data), {
|
|||
|
|
filename,
|
|||
|
|
contentType: mimetype
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const uploadResponse = await axios.post(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/storage/upload',
|
|||
|
|
formData,
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
...formData.getHeaders(),
|
|||
|
|
'xc-token': config.apiToken
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
uploaded.push(...uploadResponse.data);
|
|||
|
|
console.log(`Uploaded ${filename} from Immich to NocoDB`);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(`Failed to upload asset ${assetId}:`, e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (uploaded.length > 0) {
|
|||
|
|
// Attach to row
|
|||
|
|
await axios.patch(
|
|||
|
|
config.ncodbUrl + '/api/v1/db/data/noco/' + config.baseId + '/' + config.tableId + '/' + rowId,
|
|||
|
|
{ [config.columnName]: uploaded },
|
|||
|
|
{
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'xc-token': config.apiToken
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete from Immich if requested
|
|||
|
|
const deletedIds = [];
|
|||
|
|
if (deleteAfter && uploaded.length > 0) {
|
|||
|
|
try {
|
|||
|
|
await axios.delete(`${immichUrl}/api/assets`, {
|
|||
|
|
headers: { 'x-api-key': immichApiKey, 'Content-Type': 'application/json' },
|
|||
|
|
data: { ids: assetIds.slice(0, uploaded.length), force: false }
|
|||
|
|
});
|
|||
|
|
deletedIds.push(...assetIds.slice(0, uploaded.length));
|
|||
|
|
console.log(`Trashed ${deletedIds.length} assets from Immich`);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to delete from Immich:', e.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({ success: true, uploadedCount: uploaded.length, deletedCount: deletedIds.length });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Immich upload error:', error.message);
|
|||
|
|
res.status(500).json({ error: 'Upload failed', details: error.message });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Fetch workspace ID for NocoDB deep links
|
|||
|
|
(async () => {
|
|||
|
|
try {
|
|||
|
|
const res = await axios.get(config.ncodbUrl + '/api/v1/db/meta/projects/', {
|
|||
|
|
headers: { 'xc-token': config.apiToken }
|
|||
|
|
});
|
|||
|
|
const base = (res.data.list || []).find(b => b.id === config.baseId);
|
|||
|
|
if (base && base.fk_workspace_id) {
|
|||
|
|
config.workspaceId = base.fk_workspace_id;
|
|||
|
|
console.log(' Workspace ID: ' + config.workspaceId);
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.log(' Could not fetch workspace ID:', e.message);
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
app.listen(port, () => {
|
|||
|
|
const externalPort = process.env.EXTERNAL_PORT || port;
|
|||
|
|
console.log('📷 NocoDB Photo Uploader running on port ' + port);
|
|||
|
|
console.log('🔧 Configuration:');
|
|||
|
|
console.log(' NocoDB URL: ' + config.ncodbUrl);
|
|||
|
|
console.log(' Base ID: ' + config.baseId);
|
|||
|
|
console.log(' Table ID: ' + config.tableId);
|
|||
|
|
console.log(' Column Name: ' + config.columnName);
|
|||
|
|
console.log(' API Token: ' + (config.apiToken ? '✅ Set' : '❌ Missing'));
|
|||
|
|
console.log('');
|
|||
|
|
console.log('🌐 Open http://localhost:' + externalPort + ' to use the uploader');
|
|||
|
|
console.log('🔧 Test endpoint: http://localhost:' + externalPort + '/test');
|
|||
|
|
console.log('🎨 Modern theme preview: http://localhost:' + externalPort + '/preview');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Preview route that enables modern theme without affecting default
|
|||
|
|
app.get('/preview', (req, res) => {
|
|||
|
|
res.redirect('/?theme=modern');
|
|||
|
|
});
|