@invoicer/cli 1.1.1 ā 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/.env.example +0 -15
- package/config.json +0 -19
- package/data/last.json +0 -11
- package/index.html +0 -2584
- package/logo.svg +0 -17
- package/src/cli.ts +0 -44
- package/src/commands/clients.ts +0 -221
- package/src/commands/generate.ts +0 -379
- package/src/commands/timesheet.ts +0 -47
- package/src/core/json-store.ts +0 -33
- package/src/core/paths.ts +0 -42
- package/src/core/validators.ts +0 -168
- package/src/domain/types.ts +0 -129
- package/src/services/email.ts +0 -40
- package/src/services/invoice-number.ts +0 -46
- package/src/services/pdf-render.ts +0 -95
- package/src/services/timesheet.ts +0 -173
- package/src/utils/sanitize.ts +0 -8
- package/test/cli-wiring.test.ts +0 -25
- package/test/invoice-number.test.ts +0 -45
- package/test/timesheet.test.ts +0 -104
- package/tsconfig.json +0 -17
package/logo.svg
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
|
3
|
-
<!-- Background -->
|
|
4
|
-
<rect width="200" height="200" rx="40" fill="#1e293b"/>
|
|
5
|
-
|
|
6
|
-
<!-- "IN" Text -->
|
|
7
|
-
<text
|
|
8
|
-
x="100"
|
|
9
|
-
y="140"
|
|
10
|
-
font-family="Arial, Helvetica, sans-serif"
|
|
11
|
-
font-size="96"
|
|
12
|
-
font-weight="900"
|
|
13
|
-
fill="#ffffff"
|
|
14
|
-
text-anchor="middle"
|
|
15
|
-
letter-spacing="-8">IN</text>
|
|
16
|
-
</svg>
|
|
17
|
-
|
package/src/cli.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import "dotenv/config";
|
|
3
|
-
import { Command } from "commander";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import { createProjectPaths, type ProjectPaths } from "./core/paths.js";
|
|
6
|
-
import { registerGenerateCommand } from "./commands/generate.js";
|
|
7
|
-
import { registerClientsCommand } from "./commands/clients.js";
|
|
8
|
-
import { registerTimesheetCommand } from "./commands/timesheet.js";
|
|
9
|
-
|
|
10
|
-
export function createProgram(paths: ProjectPaths = createProjectPaths()): Command {
|
|
11
|
-
const program = new Command();
|
|
12
|
-
|
|
13
|
-
program
|
|
14
|
-
.name("invoicer")
|
|
15
|
-
.description("Professional invoice generator with timesheet integration")
|
|
16
|
-
.version("1.0.0");
|
|
17
|
-
|
|
18
|
-
registerGenerateCommand(program, paths);
|
|
19
|
-
registerClientsCommand(program, paths);
|
|
20
|
-
registerTimesheetCommand(program, paths);
|
|
21
|
-
|
|
22
|
-
return program;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function runCli(argv = process.argv, paths: ProjectPaths = createProjectPaths()): Promise<void> {
|
|
26
|
-
const program = createProgram(paths);
|
|
27
|
-
await program.parseAsync(argv);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function isMainModule(): boolean {
|
|
31
|
-
const entry = process.argv[1];
|
|
32
|
-
if (!entry) {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
return import.meta.url === pathToFileURL(entry).href;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (isMainModule()) {
|
|
39
|
-
runCli().catch((error: unknown) => {
|
|
40
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
41
|
-
console.error(`ā ${message}`);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
});
|
|
44
|
-
}
|
package/src/commands/clients.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import inquirer from "inquirer";
|
|
2
|
-
import type { Command } from "commander";
|
|
3
|
-
import { loadJsonFile, writeJsonFile } from "../core/json-store.js";
|
|
4
|
-
import { type ProjectPaths } from "../core/paths.js";
|
|
5
|
-
import { validateConfig } from "../core/validators.js";
|
|
6
|
-
import { type ClientsOptions, type Config } from "../domain/types.js";
|
|
7
|
-
|
|
8
|
-
function loadConfig(paths: ProjectPaths): Config {
|
|
9
|
-
return loadJsonFile(paths.configPath, { client: [], from: {} }, validateConfig);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function registerClientsCommand(program: Command, paths: ProjectPaths): void {
|
|
13
|
-
program
|
|
14
|
-
.command("clients")
|
|
15
|
-
.description("Manage clients")
|
|
16
|
-
.option("--list", "List all configured clients", false)
|
|
17
|
-
.option("--add", "Add a new client interactively", false)
|
|
18
|
-
.option("--edit <name>", "Edit a client by name")
|
|
19
|
-
.option("--delete <name>", "Delete a client by name")
|
|
20
|
-
.action(async (options: ClientsOptions) => {
|
|
21
|
-
const cfg = loadConfig(paths);
|
|
22
|
-
|
|
23
|
-
if (options.list) {
|
|
24
|
-
if (cfg.client.length === 0) {
|
|
25
|
-
console.log("No clients configured.");
|
|
26
|
-
process.exit(0);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
console.log("\nš Configured Clients:\n");
|
|
30
|
-
cfg.client.forEach((client, index) => {
|
|
31
|
-
console.log(`${index + 1}. ${client.name}`);
|
|
32
|
-
console.log(` Currency: ${client.currency || "USD"}`);
|
|
33
|
-
console.log(` Rate: ${client.defaultRate}/hr`);
|
|
34
|
-
console.log(` Email: ${client.email || "ā"}`);
|
|
35
|
-
if (client.address) console.log(` Address: ${client.address}`);
|
|
36
|
-
if (client.payPeriodType) console.log(` Terms: ${client.payPeriodType}`);
|
|
37
|
-
console.log();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
process.exit(0);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (options.add) {
|
|
44
|
-
const answers = await inquirer.prompt<{
|
|
45
|
-
name: string;
|
|
46
|
-
email: string;
|
|
47
|
-
address: string;
|
|
48
|
-
currency: string;
|
|
49
|
-
defaultRate: number;
|
|
50
|
-
payPeriodType: string;
|
|
51
|
-
}>([
|
|
52
|
-
{
|
|
53
|
-
type: "input",
|
|
54
|
-
name: "name",
|
|
55
|
-
message: "Client name:",
|
|
56
|
-
validate: (value: string) => value.trim().length > 0 || "Name is required",
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
type: "input",
|
|
60
|
-
name: "email",
|
|
61
|
-
message: "Email address:",
|
|
62
|
-
default: "",
|
|
63
|
-
validate: (value: string) =>
|
|
64
|
-
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Enter valid email",
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
type: "input",
|
|
68
|
-
name: "address",
|
|
69
|
-
message: "Address (optional):",
|
|
70
|
-
default: "",
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
type: "list",
|
|
74
|
-
name: "currency",
|
|
75
|
-
message: "Currency:",
|
|
76
|
-
choices: ["USD", "EUR", "GBP", "JPY"],
|
|
77
|
-
default: "USD",
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
type: "number",
|
|
81
|
-
name: "defaultRate",
|
|
82
|
-
message: "Default rate per hour:",
|
|
83
|
-
default: 50,
|
|
84
|
-
validate: (value: number) => value > 0 || "Rate must be positive",
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
type: "input",
|
|
88
|
-
name: "payPeriodType",
|
|
89
|
-
message: "Payment terms (e.g., Net 30, Due on Receipt):",
|
|
90
|
-
default: "Due on Receipt",
|
|
91
|
-
},
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
|
-
const newClient = {
|
|
95
|
-
name: answers.name.trim(),
|
|
96
|
-
email: answers.email.trim(),
|
|
97
|
-
address: answers.address.trim(),
|
|
98
|
-
currency: answers.currency,
|
|
99
|
-
defaultRate: Number(answers.defaultRate),
|
|
100
|
-
payPeriodType: answers.payPeriodType.trim(),
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
if (cfg.client.some((client) => client.name === newClient.name)) {
|
|
104
|
-
console.error(`ā Client "${newClient.name}" already exists`);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
cfg.client.push(newClient);
|
|
109
|
-
writeJsonFile(paths.configPath, cfg);
|
|
110
|
-
console.log(`ā
Client "${newClient.name}" added successfully`);
|
|
111
|
-
process.exit(0);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (options.edit) {
|
|
115
|
-
const clientName = options.edit.trim();
|
|
116
|
-
const clientIndex = cfg.client.findIndex((client) => client.name === clientName);
|
|
117
|
-
|
|
118
|
-
if (clientIndex === -1) {
|
|
119
|
-
console.error(`ā Client "${clientName}" not found`);
|
|
120
|
-
process.exit(1);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const current = cfg.client[clientIndex];
|
|
124
|
-
|
|
125
|
-
const answers = await inquirer.prompt<{
|
|
126
|
-
name: string;
|
|
127
|
-
email: string;
|
|
128
|
-
address: string;
|
|
129
|
-
currency: string;
|
|
130
|
-
defaultRate: number;
|
|
131
|
-
payPeriodType: string;
|
|
132
|
-
}>([
|
|
133
|
-
{
|
|
134
|
-
type: "input",
|
|
135
|
-
name: "name",
|
|
136
|
-
message: "Client name:",
|
|
137
|
-
default: current.name,
|
|
138
|
-
validate: (value: string) => value.trim().length > 0 || "Name is required",
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
type: "input",
|
|
142
|
-
name: "email",
|
|
143
|
-
message: "Email:",
|
|
144
|
-
default: current.email || "",
|
|
145
|
-
validate: (value: string) =>
|
|
146
|
-
!value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Enter valid email",
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
type: "input",
|
|
150
|
-
name: "address",
|
|
151
|
-
message: "Address:",
|
|
152
|
-
default: current.address || "",
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
type: "list",
|
|
156
|
-
name: "currency",
|
|
157
|
-
message: "Currency:",
|
|
158
|
-
choices: ["USD", "EUR", "GBP", "JPY"],
|
|
159
|
-
default: current.currency || "USD",
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
type: "number",
|
|
163
|
-
name: "defaultRate",
|
|
164
|
-
message: "Default rate per hour:",
|
|
165
|
-
default: current.defaultRate || 50,
|
|
166
|
-
validate: (value: number) => value > 0 || "Rate must be positive",
|
|
167
|
-
},
|
|
168
|
-
{
|
|
169
|
-
type: "input",
|
|
170
|
-
name: "payPeriodType",
|
|
171
|
-
message: "Payment terms:",
|
|
172
|
-
default: current.payPeriodType || "Due on Receipt",
|
|
173
|
-
},
|
|
174
|
-
]);
|
|
175
|
-
|
|
176
|
-
cfg.client[clientIndex] = {
|
|
177
|
-
name: answers.name.trim(),
|
|
178
|
-
email: answers.email.trim(),
|
|
179
|
-
address: answers.address.trim(),
|
|
180
|
-
currency: answers.currency,
|
|
181
|
-
defaultRate: Number(answers.defaultRate),
|
|
182
|
-
payPeriodType: answers.payPeriodType.trim(),
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
writeJsonFile(paths.configPath, cfg);
|
|
186
|
-
console.log(`ā
Client "${answers.name}" updated successfully`);
|
|
187
|
-
process.exit(0);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (options.delete) {
|
|
191
|
-
const clientName = options.delete.trim();
|
|
192
|
-
const clientIndex = cfg.client.findIndex((client) => client.name === clientName);
|
|
193
|
-
|
|
194
|
-
if (clientIndex === -1) {
|
|
195
|
-
console.error(`ā Client "${clientName}" not found`);
|
|
196
|
-
process.exit(1);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const answer = await inquirer.prompt<{ confirm: boolean }>([
|
|
200
|
-
{
|
|
201
|
-
type: "confirm",
|
|
202
|
-
name: "confirm",
|
|
203
|
-
message: `Delete client "${clientName}"? This cannot be undone.`,
|
|
204
|
-
default: false,
|
|
205
|
-
},
|
|
206
|
-
]);
|
|
207
|
-
|
|
208
|
-
if (!answer.confirm) {
|
|
209
|
-
console.log("Cancelled.");
|
|
210
|
-
process.exit(0);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
cfg.client.splice(clientIndex, 1);
|
|
214
|
-
writeJsonFile(paths.configPath, cfg);
|
|
215
|
-
console.log(`ā
Client "${clientName}" deleted`);
|
|
216
|
-
process.exit(0);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
console.log("Usage: invoicer clients [--list | --add | --edit <name> | --delete <name>]");
|
|
220
|
-
});
|
|
221
|
-
}
|
package/src/commands/generate.ts
DELETED
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import inquirer from "inquirer";
|
|
3
|
-
import type { Command } from "commander";
|
|
4
|
-
import { loadJsonFile, writeJsonFile } from "../core/json-store.js";
|
|
5
|
-
import { type ProjectPaths } from "../core/paths.js";
|
|
6
|
-
import { validateConfig, validateLastState } from "../core/validators.js";
|
|
7
|
-
import { type Config, type GenerateOptions, type LastState, type LineItem } from "../domain/types.js";
|
|
8
|
-
import {
|
|
9
|
-
getDefaultMonth,
|
|
10
|
-
getInvoiceDate,
|
|
11
|
-
getInvoicePeriod,
|
|
12
|
-
resolveInvoiceNumber,
|
|
13
|
-
validateMonth,
|
|
14
|
-
} from "../services/invoice-number.js";
|
|
15
|
-
import { hasSmtpConfig, sendInvoiceEmail } from "../services/email.js";
|
|
16
|
-
import { renderInvoicePdf } from "../services/pdf-render.js";
|
|
17
|
-
import {
|
|
18
|
-
aggregateEntriesByDescription,
|
|
19
|
-
archiveTimesheetEntries,
|
|
20
|
-
readTimesheetEntries,
|
|
21
|
-
} from "../services/timesheet.js";
|
|
22
|
-
|
|
23
|
-
function exitWithError(message: string): never {
|
|
24
|
-
console.error(message);
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function loadConfig(paths: ProjectPaths): Config {
|
|
29
|
-
return loadJsonFile(paths.configPath, { client: [], from: {} }, validateConfig);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function loadLastState(paths: ProjectPaths): LastState {
|
|
33
|
-
return loadJsonFile(paths.lastPath, { clients: {} }, validateLastState);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function getCurrency(cfgCurrency?: string): string {
|
|
37
|
-
return cfgCurrency?.trim() || "USD";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function selectClient(cfg: Config, preferredClientName?: string) {
|
|
41
|
-
if (preferredClientName) {
|
|
42
|
-
const matched = cfg.client.find((client) => client.name === preferredClientName);
|
|
43
|
-
if (!matched) {
|
|
44
|
-
exitWithError(`ā Client "${preferredClientName}" not found in config.json`);
|
|
45
|
-
}
|
|
46
|
-
console.log(`š Using client: ${matched.name}`);
|
|
47
|
-
return matched;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (cfg.client.length === 1) {
|
|
51
|
-
const [single] = cfg.client;
|
|
52
|
-
console.log(`š Using client: ${single.name}`);
|
|
53
|
-
return single;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const clientChoices = cfg.client.map((client, index) => ({
|
|
57
|
-
name: `${client.name} (${getCurrency(client.currency)} ${client.defaultRate}/hr)`,
|
|
58
|
-
value: index,
|
|
59
|
-
}));
|
|
60
|
-
|
|
61
|
-
const answer = await inquirer.prompt<{ clientIndex: number }>([
|
|
62
|
-
{
|
|
63
|
-
type: "list",
|
|
64
|
-
name: "clientIndex",
|
|
65
|
-
message: "Select client:",
|
|
66
|
-
choices: clientChoices,
|
|
67
|
-
},
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
return cfg.client[answer.clientIndex];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function collectManualLineItems(params: {
|
|
74
|
-
options: GenerateOptions;
|
|
75
|
-
cfg: Config;
|
|
76
|
-
currency: string;
|
|
77
|
-
lastHours: number;
|
|
78
|
-
lastRate: number;
|
|
79
|
-
defaultRate: number;
|
|
80
|
-
}): Promise<LineItem[]> {
|
|
81
|
-
const items: LineItem[] = [];
|
|
82
|
-
let addingItems = true;
|
|
83
|
-
let itemIndex = 1;
|
|
84
|
-
|
|
85
|
-
while (addingItems) {
|
|
86
|
-
const answer = await inquirer.prompt<{
|
|
87
|
-
description: string;
|
|
88
|
-
hours: number;
|
|
89
|
-
rate: number;
|
|
90
|
-
}>([
|
|
91
|
-
{
|
|
92
|
-
type: "input",
|
|
93
|
-
name: "description",
|
|
94
|
-
message: `Description (Item ${itemIndex}):`,
|
|
95
|
-
default:
|
|
96
|
-
itemIndex === 1
|
|
97
|
-
? (params.options.desc || params.cfg.defaultDescription || "Services")
|
|
98
|
-
: "",
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
type: "number",
|
|
102
|
-
name: "hours",
|
|
103
|
-
message: "Hours:",
|
|
104
|
-
default:
|
|
105
|
-
itemIndex === 1
|
|
106
|
-
? (params.options.hours ? Number(params.options.hours) : Number(params.lastHours ?? 160))
|
|
107
|
-
: undefined,
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
type: "number",
|
|
111
|
-
name: "rate",
|
|
112
|
-
message: "Rate per hour:",
|
|
113
|
-
default:
|
|
114
|
-
itemIndex === 1
|
|
115
|
-
? (params.options.rate ? Number(params.options.rate) : Number(params.lastRate ?? params.defaultRate))
|
|
116
|
-
: params.defaultRate,
|
|
117
|
-
},
|
|
118
|
-
]);
|
|
119
|
-
|
|
120
|
-
const hours = Number(answer.hours);
|
|
121
|
-
const rate = Number(answer.rate);
|
|
122
|
-
|
|
123
|
-
if (!Number.isFinite(hours) || !Number.isFinite(rate)) {
|
|
124
|
-
exitWithError("ā Hours and rate must be valid numbers");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const amount = hours * rate;
|
|
128
|
-
|
|
129
|
-
items.push({
|
|
130
|
-
description: answer.description,
|
|
131
|
-
hours,
|
|
132
|
-
rate,
|
|
133
|
-
amount,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
console.log(
|
|
137
|
-
` ā ${answer.description}: ${hours}h Ć ${params.currency}${rate} = ${params.currency}${amount.toFixed(2)}`,
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
const addAnotherAnswer = await inquirer.prompt<{ addAnother: boolean }>([
|
|
141
|
-
{
|
|
142
|
-
type: "confirm",
|
|
143
|
-
name: "addAnother",
|
|
144
|
-
message: "Add another line item?",
|
|
145
|
-
default: false,
|
|
146
|
-
},
|
|
147
|
-
]);
|
|
148
|
-
|
|
149
|
-
addingItems = addAnotherAnswer.addAnother;
|
|
150
|
-
itemIndex += 1;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return items;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export async function handleGenerateCommand(options: GenerateOptions, paths: ProjectPaths): Promise<void> {
|
|
157
|
-
fs.mkdirSync(paths.outDir, { recursive: true });
|
|
158
|
-
|
|
159
|
-
if (!fs.existsSync(paths.configPath)) {
|
|
160
|
-
console.error("ā Missing config.json. Create it first (see README).");
|
|
161
|
-
console.error(` Looked in: ${paths.projectRoot}`);
|
|
162
|
-
process.exit(1);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const cfg = loadConfig(paths);
|
|
166
|
-
const last = loadLastState(paths);
|
|
167
|
-
|
|
168
|
-
if (cfg.client.length === 0) {
|
|
169
|
-
exitWithError("ā No clients found in config.json");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const selectedClient = await selectClient(cfg, options.client);
|
|
173
|
-
|
|
174
|
-
const clientKey = selectedClient.name;
|
|
175
|
-
const clientHistory = last.clients[clientKey] || {
|
|
176
|
-
lastHours: 160,
|
|
177
|
-
lastRate: selectedClient.defaultRate ?? 0,
|
|
178
|
-
seqByMonth: {},
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const month = options.month || getDefaultMonth();
|
|
182
|
-
|
|
183
|
-
if (!validateMonth(month)) {
|
|
184
|
-
exitWithError("ā Month must be in YYYY-MM format");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let lineItems: LineItem[] = [];
|
|
188
|
-
let entryIdsToArchive: number[] = [];
|
|
189
|
-
|
|
190
|
-
if (options.fromTimesheet) {
|
|
191
|
-
console.log(`\nš Reading timesheet for ${selectedClient.name} (${month})...`);
|
|
192
|
-
const entries = readTimesheetEntries(paths, { client: selectedClient.name, month });
|
|
193
|
-
|
|
194
|
-
if (entries.length === 0) {
|
|
195
|
-
console.log(" No unbilled entries found for this period.");
|
|
196
|
-
console.log(" Falling back to manual entry mode.\n");
|
|
197
|
-
} else {
|
|
198
|
-
console.log(` Found ${entries.length} unbilled entries.\n`);
|
|
199
|
-
|
|
200
|
-
const aggregated = aggregateEntriesByDescription(entries);
|
|
201
|
-
|
|
202
|
-
console.log("š Aggregated by service/project:\n");
|
|
203
|
-
aggregated.forEach((group, index) => {
|
|
204
|
-
console.log(` ${index + 1}. ${group.description}`);
|
|
205
|
-
console.log(` ${group.hours}h Ć $${group.rate}/hr = $${group.amount}`);
|
|
206
|
-
if (group.rateVariance) {
|
|
207
|
-
console.log(
|
|
208
|
-
` ā ļø Rate variance: $${group.minRate}-$${group.maxRate}/hr (using weighted average)`,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
console.log(` Period: ${group.periodFrom} to ${group.periodTo}`);
|
|
212
|
-
console.log(` Based on ${group.entryIds.length} timesheet entries\n`);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
const answer = await inquirer.prompt<{ confirmUse: boolean }>([
|
|
216
|
-
{
|
|
217
|
-
type: "confirm",
|
|
218
|
-
name: "confirmUse",
|
|
219
|
-
message: "Use these aggregated line items?",
|
|
220
|
-
default: true,
|
|
221
|
-
},
|
|
222
|
-
]);
|
|
223
|
-
|
|
224
|
-
if (answer.confirmUse) {
|
|
225
|
-
lineItems = aggregated.map((group) => ({
|
|
226
|
-
description: group.description,
|
|
227
|
-
hours: group.hours,
|
|
228
|
-
rate: group.rate,
|
|
229
|
-
amount: group.amount,
|
|
230
|
-
periodFrom: group.periodFrom,
|
|
231
|
-
periodTo: group.periodTo,
|
|
232
|
-
}));
|
|
233
|
-
entryIdsToArchive = aggregated.flatMap((group) => group.entryIds);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (lineItems.length === 0) {
|
|
239
|
-
lineItems = await collectManualLineItems({
|
|
240
|
-
options,
|
|
241
|
-
cfg,
|
|
242
|
-
currency: getCurrency(selectedClient.currency),
|
|
243
|
-
lastHours: clientHistory.lastHours,
|
|
244
|
-
lastRate: clientHistory.lastRate,
|
|
245
|
-
defaultRate: selectedClient.defaultRate ?? 0,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const seqByMonth = { ...(clientHistory.seqByMonth || {}) };
|
|
250
|
-
const invoiceNumber = resolveInvoiceNumber({
|
|
251
|
-
month,
|
|
252
|
-
invoicePrefix: cfg.invoicePrefix,
|
|
253
|
-
overrideInvoice: options.invoice,
|
|
254
|
-
seqByMonth,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const invoiceDate = getInvoiceDate();
|
|
258
|
-
const { periodFrom, periodTo } = getInvoicePeriod(month);
|
|
259
|
-
|
|
260
|
-
const currency = getCurrency(selectedClient.currency);
|
|
261
|
-
const total = lineItems.reduce((sum, item) => sum + item.amount, 0);
|
|
262
|
-
|
|
263
|
-
console.log(`\nš° Total: ${currency}${total.toFixed(2)}\n`);
|
|
264
|
-
|
|
265
|
-
const outPath = await renderInvoicePdf(
|
|
266
|
-
paths,
|
|
267
|
-
{
|
|
268
|
-
from: cfg.from || {},
|
|
269
|
-
client: selectedClient,
|
|
270
|
-
currency,
|
|
271
|
-
invoiceNumber,
|
|
272
|
-
invoiceDate,
|
|
273
|
-
periodFrom,
|
|
274
|
-
periodTo,
|
|
275
|
-
lineItems,
|
|
276
|
-
paymentTerms: selectedClient.payPeriodType,
|
|
277
|
-
note: cfg.note,
|
|
278
|
-
},
|
|
279
|
-
month,
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
if (!last.clients) {
|
|
283
|
-
last.clients = {};
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const firstLineItem = lineItems[0];
|
|
287
|
-
|
|
288
|
-
last.clients[clientKey] = {
|
|
289
|
-
lastHours: firstLineItem?.hours ?? clientHistory.lastHours,
|
|
290
|
-
lastRate: firstLineItem?.rate ?? clientHistory.lastRate,
|
|
291
|
-
seqByMonth,
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
writeJsonFile(paths.lastPath, last);
|
|
295
|
-
|
|
296
|
-
console.log("ā
Invoice generated:", outPath);
|
|
297
|
-
console.log("Invoice #:", invoiceNumber);
|
|
298
|
-
console.log("Total:", `${currency} ${total.toFixed(2)}`);
|
|
299
|
-
|
|
300
|
-
if (entryIdsToArchive.length > 0) {
|
|
301
|
-
const answer = await inquirer.prompt<{ confirmArchive: boolean }>([
|
|
302
|
-
{
|
|
303
|
-
type: "confirm",
|
|
304
|
-
name: "confirmArchive",
|
|
305
|
-
message: `Archive ${entryIdsToArchive.length} timesheet entries used in this invoice?`,
|
|
306
|
-
default: true,
|
|
307
|
-
},
|
|
308
|
-
]);
|
|
309
|
-
|
|
310
|
-
if (answer.confirmArchive) {
|
|
311
|
-
const archivedCount = archiveTimesheetEntries(paths, entryIdsToArchive, invoiceNumber, month);
|
|
312
|
-
console.log(`ā
Archived ${archivedCount} entries to data/archive.json`);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (hasSmtpConfig()) {
|
|
317
|
-
const answer = await inquirer.prompt<{ sendEmail: boolean }>([
|
|
318
|
-
{
|
|
319
|
-
type: "confirm",
|
|
320
|
-
name: "sendEmail",
|
|
321
|
-
message: "Send invoice via email?",
|
|
322
|
-
default: true,
|
|
323
|
-
},
|
|
324
|
-
]);
|
|
325
|
-
|
|
326
|
-
if (answer.sendEmail) {
|
|
327
|
-
const emailAnswer = await inquirer.prompt<{ clientEmail: string }>([
|
|
328
|
-
{
|
|
329
|
-
type: "input",
|
|
330
|
-
name: "clientEmail",
|
|
331
|
-
message: "Client email address:",
|
|
332
|
-
default: selectedClient.email || "",
|
|
333
|
-
validate: (value: string) =>
|
|
334
|
-
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Enter valid email",
|
|
335
|
-
},
|
|
336
|
-
]);
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
console.log("\nš§ Sending email...");
|
|
340
|
-
|
|
341
|
-
await sendInvoiceEmail({
|
|
342
|
-
outPath,
|
|
343
|
-
invoiceNumber,
|
|
344
|
-
clientName: selectedClient.name,
|
|
345
|
-
clientEmail: emailAnswer.clientEmail,
|
|
346
|
-
currency,
|
|
347
|
-
total,
|
|
348
|
-
periodFrom,
|
|
349
|
-
periodTo,
|
|
350
|
-
senderName: cfg.from?.name,
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
console.log(`ā
Email sent successfully to ${emailAnswer.clientEmail}`);
|
|
354
|
-
} catch (error) {
|
|
355
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
356
|
-
console.error("ā Failed to send email:", message);
|
|
357
|
-
console.log(" PDF saved locally at:", outPath);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
} else {
|
|
361
|
-
console.log("\nš” Tip: Configure SMTP in .env to enable email sending");
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
export function registerGenerateCommand(program: Command, paths: ProjectPaths): void {
|
|
366
|
-
program
|
|
367
|
-
.command("generate", { isDefault: true })
|
|
368
|
-
.description("Generate an invoice from manual entry or timesheet")
|
|
369
|
-
.option("--from-timesheet", "Auto-populate from timesheet entries", false)
|
|
370
|
-
.option("--client <name>", "Client name (auto-selected if only one client)")
|
|
371
|
-
.option("--month <YYYY-MM>", "Invoice month (default: current month)")
|
|
372
|
-
.option("--hours <number>", "Hours for first line item")
|
|
373
|
-
.option("--rate <number>", "Rate for first line item")
|
|
374
|
-
.option("--desc <text>", "Description for first line item")
|
|
375
|
-
.option("--invoice <number>", "Custom invoice number")
|
|
376
|
-
.action(async (options: GenerateOptions) => {
|
|
377
|
-
await handleGenerateCommand(options, paths);
|
|
378
|
-
});
|
|
379
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
2
|
-
import { type ProjectPaths } from "../core/paths.js";
|
|
3
|
-
import { type TimesheetOptions } from "../domain/types.js";
|
|
4
|
-
import {
|
|
5
|
-
readArchivedEntries,
|
|
6
|
-
readTimesheetEntries,
|
|
7
|
-
toArchiveCsv,
|
|
8
|
-
toTimesheetCsv,
|
|
9
|
-
} from "../services/timesheet.js";
|
|
10
|
-
|
|
11
|
-
export function registerTimesheetCommand(program: Command, paths: ProjectPaths): void {
|
|
12
|
-
program
|
|
13
|
-
.command("timesheet")
|
|
14
|
-
.description("Manage timesheet data")
|
|
15
|
-
.option("--export <format>", "Export timesheet (csv|json)", "")
|
|
16
|
-
.option("--client <name>", "Filter by client")
|
|
17
|
-
.option("--month <YYYY-MM>", "Filter by month")
|
|
18
|
-
.option("--archive", "Show archived entries", false)
|
|
19
|
-
.action(async (options: TimesheetOptions) => {
|
|
20
|
-
if (options.archive) {
|
|
21
|
-
const archived = readArchivedEntries(paths, {
|
|
22
|
-
client: options.client || "",
|
|
23
|
-
month: options.month || "",
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
if (options.export === "csv") {
|
|
27
|
-
console.log(toArchiveCsv(archived));
|
|
28
|
-
process.exit(0);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
console.log(JSON.stringify(archived, null, 2));
|
|
32
|
-
process.exit(0);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const entries = readTimesheetEntries(paths, {
|
|
36
|
-
client: options.client || "",
|
|
37
|
-
month: options.month || "",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
if (options.export === "csv") {
|
|
41
|
-
console.log(toTimesheetCsv(entries));
|
|
42
|
-
process.exit(0);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
console.log(JSON.stringify(entries, null, 2));
|
|
46
|
-
});
|
|
47
|
-
}
|