Add FreeScout time tracking CLI with Excel invoice generation
This commit is contained in:
parent
88229d7e5e
commit
749ee04ed7
6 changed files with 1406 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
.credentials/
|
||||
node_modules/
|
||||
|
|
|
|||
2
SETUP.md
2
SETUP.md
|
|
@ -13,3 +13,5 @@ Track everything installed on this machine so it can be reproduced.
|
|||
|
||||
| Date | Package | Method | Notes |
|
||||
|------|---------|--------|-------|
|
||||
| 2026-02-09 | mysql2 | npm install | MySQL driver for FreeScout DB access |
|
||||
| 2026-02-09 | exceljs | npm install | Excel generation for invoices |
|
||||
|
|
|
|||
20
TOOLS.md
20
TOOLS.md
|
|
@ -176,6 +176,26 @@ ainews reset # Clear seen history
|
|||
|
||||
---
|
||||
|
||||
## FreeScout Time Tracking
|
||||
|
||||
Helper script: `~/bin/freescout`
|
||||
|
||||
```bash
|
||||
freescout mailboxes # List mailboxes
|
||||
freescout users # List users
|
||||
freescout report --from DATE --to DATE # Show time report (tab-separated)
|
||||
[--mailbox ID] [--user ID]
|
||||
freescout excel --from DATE --to DATE # Generate invoice Excel
|
||||
[--mailbox ID] [--user ID] [--output file.xlsx]
|
||||
```
|
||||
|
||||
- Credentials: `.credentials/services.env` (CLOONAR_DB_* + FREESCOUT_DB_NAME)
|
||||
- Queries timelogs table directly (API doesn't expose time tracking)
|
||||
- Excel groups by conversation, shows subject, customer, hours
|
||||
- `time_spent` is stored in seconds, displayed as decimal hours
|
||||
|
||||
---
|
||||
|
||||
## Brain Dump CLI
|
||||
|
||||
Helper script: `~/clawd/bin/tasks`
|
||||
|
|
|
|||
236
bin/freescout
Executable file
236
bin/freescout
Executable file
|
|
@ -0,0 +1,236 @@
|
|||
#!/usr/bin/env node
|
||||
// FreeScout time tracking CLI — query timelogs and generate invoice Excel
|
||||
const mysql = require('mysql2/promise');
|
||||
const ExcelJS = require('exceljs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const credFile = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env');
|
||||
const env = {};
|
||||
fs.readFileSync(credFile, 'utf8').split('\n').forEach(l => { const m = l.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; });
|
||||
|
||||
async function getConn() {
|
||||
return mysql.createConnection({
|
||||
host: env.CLOONAR_DB_HOST,
|
||||
port: parseInt(env.CLOONAR_DB_PORT || '3306'),
|
||||
user: env.CLOONAR_DB_USER,
|
||||
password: env.CLOONAR_DB_PASS,
|
||||
database: env.FREESCOUT_DB_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
function formatHours(seconds) {
|
||||
const h = seconds / 3600;
|
||||
return Math.round(h * 100) / 100;
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
|
||||
async function cmdMailboxes() {
|
||||
const conn = await getConn();
|
||||
const [rows] = await conn.query('SELECT id, name FROM mailboxes ORDER BY name');
|
||||
console.log('ID\tNAME');
|
||||
rows.forEach(r => console.log(`${r.id}\t${r.name}`));
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
async function cmdUsers() {
|
||||
const conn = await getConn();
|
||||
const [rows] = await conn.query('SELECT id, first_name, last_name, email FROM users ORDER BY first_name');
|
||||
console.log('ID\tNAME\tEMAIL');
|
||||
rows.forEach(r => console.log(`${r.id}\t${r.first_name} ${r.last_name}\t${r.email}`));
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
async function cmdReport(args) {
|
||||
const mailboxId = getFlag(args, '--mailbox');
|
||||
const from = getFlag(args, '--from');
|
||||
const to = getFlag(args, '--to');
|
||||
const userId = getFlag(args, '--user');
|
||||
|
||||
if (!from || !to) {
|
||||
console.error('Usage: freescout report --from YYYY-MM-DD --to YYYY-MM-DD [--mailbox ID] [--user ID]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const conn = await getConn();
|
||||
let query = `
|
||||
SELECT
|
||||
t.id as timelog_id,
|
||||
t.time_spent,
|
||||
t.created_at,
|
||||
c.id as conversation_id,
|
||||
c.number as conversation_number,
|
||||
c.subject,
|
||||
c.customer_email,
|
||||
m.name as mailbox_name,
|
||||
u.first_name,
|
||||
u.last_name
|
||||
FROM timelogs t
|
||||
JOIN conversations c ON t.conversation_id = c.id
|
||||
JOIN mailboxes m ON c.mailbox_id = m.id
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.created_at >= ? AND t.created_at < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
`;
|
||||
const params = [from, to];
|
||||
|
||||
if (mailboxId) { query += ' AND c.mailbox_id = ?'; params.push(mailboxId); }
|
||||
if (userId) { query += ' AND t.user_id = ?'; params.push(userId); }
|
||||
|
||||
query += ' ORDER BY t.created_at';
|
||||
|
||||
const [rows] = await conn.query(query, params);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('No timelogs found for this period.');
|
||||
await conn.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('DATE\tCONV#\tSUBJECT\tCUSTOMER\tUSER\tHOURS\tMAILBOX');
|
||||
let totalSeconds = 0;
|
||||
rows.forEach(r => {
|
||||
const date = new Date(r.created_at).toISOString().slice(0, 10);
|
||||
const hours = formatHours(r.time_spent);
|
||||
totalSeconds += r.time_spent;
|
||||
console.log(`${date}\t#${r.conversation_number}\t${r.subject || '(no subject)'}\t${r.customer_email || '-'}\t${r.first_name} ${r.last_name}\t${hours}\t${r.mailbox_name}`);
|
||||
});
|
||||
console.log(`\nTotal: ${formatHours(totalSeconds)} hours (${rows.length} entries)`);
|
||||
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
async function cmdExcel(args) {
|
||||
const mailboxId = getFlag(args, '--mailbox');
|
||||
const from = getFlag(args, '--from');
|
||||
const to = getFlag(args, '--to');
|
||||
const userId = getFlag(args, '--user');
|
||||
const output = getFlag(args, '--output') || `invoice-${from}-${to}.xlsx`;
|
||||
|
||||
if (!from || !to) {
|
||||
console.error('Usage: freescout excel --from YYYY-MM-DD --to YYYY-MM-DD [--mailbox ID] [--user ID] [--output file.xlsx]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const conn = await getConn();
|
||||
let query = `
|
||||
SELECT
|
||||
t.time_spent,
|
||||
t.created_at,
|
||||
c.number as conversation_number,
|
||||
c.subject,
|
||||
c.customer_email,
|
||||
m.name as mailbox_name,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
cu.first_name as customer_first,
|
||||
cu.last_name as customer_last,
|
||||
cu.company as customer_company
|
||||
FROM timelogs t
|
||||
JOIN conversations c ON t.conversation_id = c.id
|
||||
JOIN mailboxes m ON c.mailbox_id = m.id
|
||||
JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN customers cu ON c.customer_id = cu.id
|
||||
WHERE t.created_at >= ? AND t.created_at < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
`;
|
||||
const params = [from, to];
|
||||
|
||||
if (mailboxId) { query += ' AND c.mailbox_id = ?'; params.push(mailboxId); }
|
||||
if (userId) { query += ' AND t.user_id = ?'; params.push(userId); }
|
||||
|
||||
query += ' ORDER BY t.created_at';
|
||||
|
||||
const [rows] = await conn.query(query, params);
|
||||
await conn.end();
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('No timelogs found for this period.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by conversation
|
||||
const byConv = {};
|
||||
rows.forEach(r => {
|
||||
const key = r.conversation_number;
|
||||
if (!byConv[key]) {
|
||||
byConv[key] = {
|
||||
number: r.conversation_number,
|
||||
subject: r.subject || '(no subject)',
|
||||
customer_email: r.customer_email || '-',
|
||||
customer_name: [r.customer_first, r.customer_last].filter(Boolean).join(' ') || '-',
|
||||
customer_company: r.customer_company || '-',
|
||||
mailbox: r.mailbox_name,
|
||||
totalSeconds: 0,
|
||||
entries: []
|
||||
};
|
||||
}
|
||||
byConv[key].totalSeconds += r.time_spent;
|
||||
byConv[key].entries.push(r);
|
||||
});
|
||||
|
||||
// Build Excel
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('Time Report');
|
||||
|
||||
// Header
|
||||
ws.columns = [
|
||||
{ header: 'Conv #', key: 'number', width: 10 },
|
||||
{ header: 'Subject', key: 'subject', width: 40 },
|
||||
{ header: 'Customer', key: 'customer', width: 25 },
|
||||
{ header: 'Company', key: 'company', width: 25 },
|
||||
{ header: 'Hours', key: 'hours', width: 10 },
|
||||
];
|
||||
|
||||
// Style header
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
let totalSeconds = 0;
|
||||
Object.values(byConv).forEach(conv => {
|
||||
totalSeconds += conv.totalSeconds;
|
||||
ws.addRow({
|
||||
number: `#${conv.number}`,
|
||||
subject: conv.subject,
|
||||
customer: conv.customer_name !== '-' ? conv.customer_name : conv.customer_email,
|
||||
company: conv.customer_company,
|
||||
hours: formatHours(conv.totalSeconds),
|
||||
});
|
||||
});
|
||||
|
||||
// Total row
|
||||
const totalRow = ws.addRow({ number: '', subject: '', customer: '', company: 'TOTAL', hours: formatHours(totalSeconds) });
|
||||
totalRow.font = { bold: true };
|
||||
|
||||
await wb.xlsx.writeFile(output);
|
||||
console.log(`Written: ${output}`);
|
||||
console.log(`${Object.keys(byConv).length} conversations, ${formatHours(totalSeconds)} hours total`);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getFlag(args, flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx === -1) return null;
|
||||
return args[idx + 1] || null;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
|
||||
const commands_map = { mailboxes: cmdMailboxes, users: cmdUsers, report: cmdReport, excel: cmdExcel };
|
||||
|
||||
if (!cmd || !commands_map[cmd]) {
|
||||
console.log(`Usage: freescout <command> [args]
|
||||
|
||||
Commands:
|
||||
mailboxes List mailboxes
|
||||
users List users
|
||||
report --from DATE --to DATE Show time report (tab-separated)
|
||||
[--mailbox ID] [--user ID]
|
||||
excel --from DATE --to DATE Generate invoice Excel
|
||||
[--mailbox ID] [--user ID] [--output file.xlsx]`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
commands_map[cmd](args).catch(e => { console.error(e.message); process.exit(1); });
|
||||
1127
package-lock.json
generated
Normal file
1127
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "workspace",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.cloonar.com/openclawd/config.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"exceljs": "^4.4.0",
|
||||
"mysql2": "^3.16.3"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue