@dayofweek/dcli 1.10.0 → 1.12.0

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/README.md CHANGED
@@ -153,6 +153,35 @@ dcli data get <dataset> --limit 100 --json
153
153
  Responses are `{ dataset, total, truncated, rows }`. The CLI has no built-in
154
154
  dataset names; new datasets appear in the listing without a CLI update.
155
155
 
156
+ ## Time tracking (staff)
157
+
158
+ Hours registered against time projects. The server owns every rule — project
159
+ and participation periods, hour frames, self-financing, the reportable basis,
160
+ who may approve — and the CLI only carries JSON.
161
+
162
+ ```bash
163
+ dcli time projects list --json
164
+ dcli time projects show <projectId> --json
165
+ dcli time projects upsert --file project.json # create, or update with projectId
166
+
167
+ dcli time entries list --project <id> --from 2026-06-01 --to 2026-09-30 --json
168
+ dcli time entries import --project <id> --file entries.json --dry-run
169
+ dcli time entries import --project <id> --file entries.json --approved
170
+
171
+ dcli time months list --json
172
+ dcli time months submit --project <id> --month 2026-08
173
+ dcli time months approve --project <id> --month 2026-08 --user-email someone@example.com
174
+ dcli time months return --project <id> --month 2026-08 --user-email ... --comment "..."
175
+ ```
176
+
177
+ `entries import` is idempotent: each entry carries an import key (explicit, or
178
+ derived from person + date + activity + description), so re-running a file
179
+ updates rather than duplicates. Approved rows are locked and reported as
180
+ `skip_locked`; any invalid entry aborts the whole batch with nothing written.
181
+ Because real rows are written, the command refuses to run without `--dry-run`
182
+ first and `--approved` afterwards. Payload shapes are documented in the
183
+ `time-tracking` reference that `dcli skill install` fetches for staff.
184
+
156
185
  ## Feedback backlog
157
186
 
158
187
  The customer feedback backlog that humans and coding agents work together.
@@ -188,6 +217,38 @@ customer is irreversible and outward-facing, so it cannot happen as a side
188
217
  effect of reading the inbox — a person approves the exact text first, and
189
218
  `--approved` records that they did.
190
219
 
220
+ ## Cross-org reads (staff)
221
+
222
+ Admin tokens can read across every organization. Both listings are
223
+ cursor-paged: filters apply within each scanned page, so a page can be short
224
+ or empty while more rows remain — `--all` walks the pages for you.
225
+
226
+ ```bash
227
+ dcli admin entities --category supply --all --json # every producer, every org
228
+ dcli admin entities --role producer --with-roles --json # first page, roles attached
229
+ dcli admin entities --search huseby --json
230
+ dcli admin entities --limit 500 --cursor <nextCursor> --json
231
+
232
+ dcli admin market-intel stats --json # the market intelligence register, counted
233
+ dcli admin market-intel list --type farm --region Agder --all --json
234
+ dcli admin market-intel list --bbox 57.95,6.35,59.70,9.40 --json
235
+ dcli admin market-intel get <id> --json
236
+ ```
237
+
238
+ Entity rows carry `typeName` and `typeCategory` resolved from the entity's
239
+ type — count on those, not on the legacy `entityType` string. The market
240
+ intelligence register is a separate table from the entity hierarchy (every row
241
+ has coordinates; `linkedEntity` names the hierarchy entity it points at, if
242
+ any), so "how many producers do we know of" reads both.
243
+
244
+ An area's actors can be tied to the platform entity they are:
245
+
246
+ ```bash
247
+ dcli brain actors list --area <areaId> --match --json # unlinked actors get name-matched candidates (admins)
248
+ dcli brain actors link --area <areaId> --actor <actorId> --entity <entityId>
249
+ dcli brain actors link --area <areaId> --actor <actorId> --unlink
250
+ ```
251
+
191
252
  ## Legacy platform commands
192
253
 
193
254
  Existing read/proposal workflows remain compatible:
package/dist/bin/dcli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { ApiError, DayOfWeekClient, toArrayBuffer } from "../client.js";
3
+ import { ApiError, DayOfWeekClient, toArrayBuffer, collectAdminPages } from "../client.js";
4
4
  import { getToken, getApiUrl, saveConfig, loadConfig, saveCredential, deleteCredential } from "../config.js";
5
5
  import { browserLogin } from "../auth/login.js";
6
6
  import { parseBrainResource } from "../uri.js";
@@ -326,8 +326,29 @@ brainActors
326
326
  .command("list")
327
327
  .description("List an area's actors (active, sorted by name)")
328
328
  .requiredOption("--area <areaId>", "Area id (from `brain list`)")
329
+ .option("--match", "Suggest matching platform entities for unlinked actors (admins)")
329
330
  .action(async (opts) => {
330
- output(await getClient().listBrainActors(opts.area));
331
+ output(await getClient().listBrainActors(opts.area, { match: opts.match }));
332
+ });
333
+ brainActors
334
+ .command("link")
335
+ .description("Link an actor to the platform entity it is (or --unlink)")
336
+ .requiredOption("--area <areaId>", "Area id (from `brain list`)")
337
+ .requiredOption("--actor <actorId>", "Actor id (from `brain actors list`)")
338
+ .option("--entity <entityId>", "Platform entity id (from `admin entities` or `read entities`)")
339
+ .option("--unlink", "Remove the current link")
340
+ .action(async (opts) => {
341
+ if (!opts.entity && !opts.unlink) {
342
+ throw new Error("Provide --entity <entityId> to link, or --unlink to remove the link");
343
+ }
344
+ if (opts.entity && opts.unlink) {
345
+ throw new Error("--entity and --unlink are mutually exclusive");
346
+ }
347
+ output(await getClient().linkBrainActor({
348
+ areaId: opts.area,
349
+ actorId: opts.actor,
350
+ entityId: opts.unlink ? null : opts.entity,
351
+ }));
331
352
  });
332
353
  brainActors
333
354
  .command("add")
@@ -1330,6 +1351,127 @@ feedback
1330
1351
  rejectedReason: opts.rejectedReason,
1331
1352
  }));
1332
1353
  });
1354
+ // ── Time tracking ────────────────────────────────────────────────────────────
1355
+ //
1356
+ // Hours per project. The server owns the rules (project and participation
1357
+ // periods, hour frames, self-financing, the reportable basis, approvals);
1358
+ // this group parses flags, reads JSON files and prints the response. The
1359
+ // only local rule is the irreversibility gate on import: real rows are
1360
+ // written, so --dry-run first, then --approved.
1361
+ const time = program.command("time").description("Hours per project: projects, entries, monthly sheets");
1362
+ const timeProjects = time.command("projects").description("Time projects: setup and overview");
1363
+ timeProjects
1364
+ .command("list")
1365
+ .description("List time projects with totals and frame usage")
1366
+ .option("--org <org>", "Organization slug or id (admin only)")
1367
+ .action(async (opts) => {
1368
+ output(await getClient().listTimeProjects(opts.org));
1369
+ });
1370
+ timeProjects
1371
+ .command("show <projectId>")
1372
+ .description("One project: setup, person×activity×month matrix, entries with computed split, monthly sheets")
1373
+ .option("--org <org>", "Organization slug or id (admin only)")
1374
+ .action(async (projectId, opts) => {
1375
+ output(await getClient().getTimeProject(projectId, opts.org));
1376
+ });
1377
+ timeProjects
1378
+ .command("upsert")
1379
+ .description("Create or update a time project from a JSON file (include projectId to update)")
1380
+ .requiredOption("--file <path>", "JSON payload with the project setup (- for stdin)")
1381
+ .option("--org <org>", "Organization slug or id (admin only)")
1382
+ .action(async (opts) => {
1383
+ const raw = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1384
+ const payload = JSON.parse(raw);
1385
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1386
+ throw new Error("Payload must be a JSON object with the project setup");
1387
+ }
1388
+ output(await getClient().upsertTimeProject(payload, opts.org));
1389
+ });
1390
+ const timeEntries = time.command("entries").description("Registered hours");
1391
+ timeEntries
1392
+ .command("list")
1393
+ .description("Flat rows with the computed split (counted, self-financed, reportable, reasons)")
1394
+ .option("--project <projectId>", "Only this project")
1395
+ .option("--user <userId>", "Only this person (owners and admins)")
1396
+ .option("--from <YYYY-MM-DD>", "First work date")
1397
+ .option("--to <YYYY-MM-DD>", "Last work date")
1398
+ .option("--status <status>", "draft | submitted | approved")
1399
+ .option("--org <org>", "Organization slug or id (admin only)")
1400
+ .action(async (opts) => {
1401
+ output(await getClient().listTimeEntries({
1402
+ project: opts.project,
1403
+ user: opts.user,
1404
+ from: opts.from,
1405
+ to: opts.to,
1406
+ status: opts.status,
1407
+ org: opts.org,
1408
+ }));
1409
+ });
1410
+ timeEntries
1411
+ .command("import")
1412
+ .description("Idempotent batch import of hours into one project. Requires --dry-run or --approved")
1413
+ .requiredOption("--project <projectId>", "Target time project")
1414
+ .requiredOption("--file <path>", "JSON: { entries: [...] } or a bare array (- for stdin)")
1415
+ .option("--dry-run", "Validate and report what would happen, write nothing")
1416
+ .option("--approved", "The operator approved the dry-run result")
1417
+ .option("--org <org>", "Organization slug or id (admin only)")
1418
+ .action(async (opts) => {
1419
+ if (!opts.dryRun && !opts.approved) {
1420
+ throw new Error("Refusing to write: run with --dry-run first, show the operator the summary, " +
1421
+ "then repeat with --approved. This writes real hours, not proposals.");
1422
+ }
1423
+ const raw = opts.file === "-" ? readFileSync(0, "utf8") : readFileSync(opts.file, "utf8");
1424
+ const payload = JSON.parse(raw);
1425
+ const entries = Array.isArray(payload) ? payload : payload?.entries;
1426
+ if (!Array.isArray(entries) || entries.length === 0) {
1427
+ throw new Error("Payload needs { entries: [...] } with at least one entry");
1428
+ }
1429
+ output(await getClient().importTimeEntries({
1430
+ projectId: opts.project,
1431
+ entries,
1432
+ dryRun: Boolean(opts.dryRun),
1433
+ org: opts.org,
1434
+ }));
1435
+ });
1436
+ const timeMonths = time.command("months").description("Monthly sheets: submit, approve, return, reopen");
1437
+ timeMonths
1438
+ .command("list")
1439
+ .description("Months waiting for your approval (project owner), or every submitted month (admin)")
1440
+ .option("--org <org>", "Organization slug or id (admin only)")
1441
+ .action(async (opts) => {
1442
+ output(await getClient().listPendingTimeMonths(opts.org));
1443
+ });
1444
+ for (const action of ["submit", "approve", "return", "reopen"]) {
1445
+ const descriptions = {
1446
+ submit: "Submit a month for approval (your own; admins may pass --user-email)",
1447
+ approve: "Approve a submitted month — locks its rows (project owner or admin)",
1448
+ return: "Return a submitted month as drafts, --comment required (project owner or admin)",
1449
+ reopen: "Reopen an approved month for correction; it needs a new approval",
1450
+ };
1451
+ timeMonths
1452
+ .command(action)
1453
+ .description(descriptions[action])
1454
+ .requiredOption("--project <projectId>", "Time project")
1455
+ .requiredOption("--month <YYYY-MM>", "Month")
1456
+ .option("--user-email <email>", "Whose month (defaults to yourself)")
1457
+ .option("--user <userId>", "Whose month, by user id")
1458
+ .option("--comment <text>", "Comment (required for return)")
1459
+ .option("--org <org>", "Organization slug or id (admin only)")
1460
+ .action(async (opts) => {
1461
+ if (action === "return" && !opts.comment) {
1462
+ throw new Error("--comment is required when returning a month");
1463
+ }
1464
+ output(await getClient().actOnTimeMonth({
1465
+ projectId: opts.project,
1466
+ month: opts.month,
1467
+ action,
1468
+ userId: opts.user,
1469
+ userEmail: opts.userEmail,
1470
+ comment: opts.comment,
1471
+ org: opts.org,
1472
+ }));
1473
+ });
1474
+ }
1333
1475
  // ── Admin Commands ───────────────────────────────────────────────────────────
1334
1476
  //
1335
1477
  // These are admin-only. They're registered as hidden subcommands when the
@@ -1349,22 +1491,86 @@ function registerAdminCommands() {
1349
1491
  .description("Admin-only cross-org operations (DoW staff)");
1350
1492
  admin
1351
1493
  .command("entities")
1352
- .description("List entities across all orgs with admin filters")
1353
- .option("--type <entityType>", "Filter by entity type")
1494
+ .description("List entities across all orgs with admin filters (cursor-paged)")
1495
+ .option("--type <entityType>", "Filter by type name (farm, producer, restaurant, …)")
1496
+ .option("--category <category>", "Filter by type category (supply, demand, partner, financial, …)")
1497
+ .option("--role <role>", "Only entities holding an active role: customer, partner, investor, producer, market_intel")
1498
+ .option("--with-roles", "Attach each entity's active roles")
1354
1499
  .option("--missing-location", "Only entities lacking metadata.places[].lat/lng")
1355
1500
  .option("--search <query>", "Substring match on name")
1356
1501
  .option("--org <slug>", "Restrict to a single org slug or ID")
1357
- .option("--limit <count>", "Max results (default 200)", parseInt)
1502
+ .option("--limit <count>", "Rows scanned per page (default 200, max 2000)", parseInt)
1503
+ .option("--cursor <cursor>", "Continue from a previous page's nextCursor")
1504
+ .option("--all", "Walk every page and return all matches")
1358
1505
  .action(async (opts) => {
1359
1506
  const client = getClient();
1360
- const result = await client.adminListEntities({
1507
+ const fetchPage = (cursor) => client.adminListEntities({
1361
1508
  org: opts.org,
1362
1509
  type: opts.type,
1510
+ category: opts.category,
1511
+ role: opts.role,
1512
+ withRoles: opts.withRoles,
1363
1513
  missingLocation: opts.missingLocation,
1364
1514
  search: opts.search,
1365
1515
  limit: opts.limit,
1516
+ cursor,
1366
1517
  });
1367
- output(result);
1518
+ output(opts.all ? await collectAdminPages(fetchPage, opts.cursor) : await fetchPage(opts.cursor));
1519
+ });
1520
+ // The market intelligence register is a separate table from the entity
1521
+ // hierarchy: businesses recorded for research and prospecting, each with
1522
+ // coordinates. A platform-wide "how many do we know of" reads both.
1523
+ const marketIntel = admin
1524
+ .command("market-intel")
1525
+ .description("The market intelligence register — businesses recorded for research and prospecting");
1526
+ marketIntel
1527
+ .command("list")
1528
+ .description("List register rows (cursor-paged, newest first)")
1529
+ .option("--type <entityType>", "farm, restaurant, farm_shop, retail_store, distributor, processor, market, cooperative, supplier, customer, competitor, other")
1530
+ .option("--relationship <type>", "prospect, active_customer, supplier, partner, competitor, market_research, other")
1531
+ .option("--priority <priority>", "critical, high, medium, low, watching")
1532
+ .option("--stage <stage>", "Opportunity stage (research, qualified_lead, negotiation, …)")
1533
+ .option("--region <region>", "Exact match on region (case-insensitive)")
1534
+ .option("--search <query>", "Substring match on name")
1535
+ .option("--bbox <latMin,lngMin,latMax,lngMax>", "Only rows inside a lat/lng box")
1536
+ .option("--active", "Only active rows")
1537
+ .option("--inactive", "Only inactive rows")
1538
+ .option("--org <slug>", "Restrict to the owning org")
1539
+ .option("--limit <count>", "Rows scanned per page (default 200, max 2000)", parseInt)
1540
+ .option("--cursor <cursor>", "Continue from a previous page's nextCursor")
1541
+ .option("--all", "Walk every page and return all matches")
1542
+ .action(async (opts) => {
1543
+ if (opts.active && opts.inactive) {
1544
+ throw new Error("--active and --inactive are mutually exclusive");
1545
+ }
1546
+ const client = getClient();
1547
+ const fetchPage = (cursor) => client.adminListMarketIntel({
1548
+ org: opts.org,
1549
+ type: opts.type,
1550
+ relationship: opts.relationship,
1551
+ priority: opts.priority,
1552
+ stage: opts.stage,
1553
+ region: opts.region,
1554
+ search: opts.search,
1555
+ bbox: opts.bbox,
1556
+ active: opts.active ? true : opts.inactive ? false : undefined,
1557
+ limit: opts.limit,
1558
+ cursor,
1559
+ });
1560
+ output(opts.all ? await collectAdminPages(fetchPage, opts.cursor) : await fetchPage(opts.cursor));
1561
+ });
1562
+ marketIntel
1563
+ .command("get <id>")
1564
+ .description("One register record with its latest interactions")
1565
+ .action(async (id) => {
1566
+ output(await getClient().adminGetMarketIntel(id));
1567
+ });
1568
+ marketIntel
1569
+ .command("stats")
1570
+ .description("Counts by type, relationship, priority, stage, region and country")
1571
+ .option("--org <slug>", "Restrict to the owning org")
1572
+ .action(async (opts) => {
1573
+ output(await getClient().adminMarketIntelStats(opts.org));
1368
1574
  });
1369
1575
  admin
1370
1576
  .command("proposals")
@@ -4209,6 +4209,21 @@ var ApiError = class extends Error {
4209
4209
  this.name = "ApiError";
4210
4210
  }
4211
4211
  };
4212
+ async function collectAdminPages(fetchPage, startCursor) {
4213
+ const results = [];
4214
+ let cursor = startCursor;
4215
+ let scanned = 0;
4216
+ let pages = 0;
4217
+ for (; ; ) {
4218
+ const page = await fetchPage(cursor);
4219
+ results.push(...page.results);
4220
+ scanned += page.scanned ?? page.results.length;
4221
+ pages += 1;
4222
+ if (page.isDone || !page.nextCursor) break;
4223
+ cursor = page.nextCursor;
4224
+ }
4225
+ return { results, total: results.length, scanned, isDone: true, nextCursor: null, truncated: false, pages };
4226
+ }
4212
4227
  var DayOfWeekClient = class {
4213
4228
  baseUrl;
4214
4229
  token;
@@ -4427,12 +4442,43 @@ var DayOfWeekClient = class {
4427
4442
  const params = new URLSearchParams();
4428
4443
  if (opts?.org) params.set("org", opts.org);
4429
4444
  if (opts?.type) params.set("type", opts.type);
4445
+ if (opts?.category) params.set("category", opts.category);
4446
+ if (opts?.role) params.set("role", opts.role);
4447
+ if (opts?.withRoles) params.set("with-roles", "1");
4430
4448
  if (opts?.missingLocation) params.set("missing-location", "1");
4431
4449
  if (opts?.search) params.set("search", opts.search);
4432
4450
  if (opts?.limit) params.set("limit", String(opts.limit));
4451
+ if (opts?.cursor) params.set("cursor", opts.cursor);
4433
4452
  const qs = params.toString();
4434
4453
  return this.get(`/admin/entities${qs ? `?${qs}` : ""}`);
4435
4454
  }
4455
+ // The market intelligence register: businesses recorded for research and
4456
+ // prospecting, each with coordinates — a separate table from the entity
4457
+ // hierarchy, so a platform-wide count has to read both.
4458
+ async adminListMarketIntel(opts) {
4459
+ const params = new URLSearchParams();
4460
+ if (opts?.org) params.set("org", opts.org);
4461
+ if (opts?.type) params.set("type", opts.type);
4462
+ if (opts?.relationship) params.set("relationship", opts.relationship);
4463
+ if (opts?.priority) params.set("priority", opts.priority);
4464
+ if (opts?.stage) params.set("stage", opts.stage);
4465
+ if (opts?.region) params.set("region", opts.region);
4466
+ if (opts?.search) params.set("search", opts.search);
4467
+ if (opts?.bbox) params.set("bbox", opts.bbox);
4468
+ if (opts?.active !== void 0) params.set("active", opts.active ? "1" : "0");
4469
+ if (opts?.limit) params.set("limit", String(opts.limit));
4470
+ if (opts?.cursor) params.set("cursor", opts.cursor);
4471
+ const qs = params.toString();
4472
+ return this.get(`/admin/market-intel${qs ? `?${qs}` : ""}`);
4473
+ }
4474
+ /** One register record in full, with its latest interactions. */
4475
+ async adminGetMarketIntel(id) {
4476
+ return this.get(`/admin/market-intel/${encodeURIComponent(id)}`);
4477
+ }
4478
+ /** Counts over the whole register — by type, relationship, priority, stage, region, country. */
4479
+ async adminMarketIntelStats(org) {
4480
+ return this.get(`/admin/market-intel/stats${org ? `?org=${encodeURIComponent(org)}` : ""}`);
4481
+ }
4436
4482
  async adminListProposals(opts) {
4437
4483
  const params = new URLSearchParams();
4438
4484
  if (opts?.status) params.set("status", opts.status);
@@ -4472,9 +4518,19 @@ var DayOfWeekClient = class {
4472
4518
  async listBrainSources(areaId) {
4473
4519
  return this.get(`/brain/sources?area=${encodeURIComponent(areaId)}`);
4474
4520
  }
4475
- /** An area's actors (people and organizations, the Actors tab). Active only. */
4476
- async listBrainActors(areaId) {
4477
- return this.get(`/brain/actors?area=${encodeURIComponent(areaId)}`);
4521
+ /**
4522
+ * An area's actors (people and organizations, the Actors tab). Active only.
4523
+ * With `match`, admins also get `candidates` on unlinked actors — the
4524
+ * platform entities whose name matches — to review before linking.
4525
+ */
4526
+ async listBrainActors(areaId, opts) {
4527
+ const params = new URLSearchParams({ area: areaId });
4528
+ if (opts?.match) params.set("match", "1");
4529
+ return this.get(`/brain/actors?${params.toString()}`);
4530
+ }
4531
+ /** Link an actor to the platform entity it is; `entityId: null` unlinks. */
4532
+ async linkBrainActor(input) {
4533
+ return this.post("/brain/actors/link", input);
4478
4534
  }
4479
4535
  /**
4480
4536
  * Create an actor in an area. Idempotent on (area, name): an existing active
@@ -4717,6 +4773,52 @@ var DayOfWeekClient = class {
4717
4773
  async archiveSharedSkill(skillId) {
4718
4774
  return this.delete(`/brain/skills/${encodeURIComponent(skillId)}`);
4719
4775
  }
4776
+ // ── Time tracking ─────────────────────────────────────────────────────────
4777
+ //
4778
+ // Hours registered against time projects. Every rule (periods, frames,
4779
+ // self-financing, the reportable basis, who may approve) is evaluated by
4780
+ // the server; the client only carries JSON and prints what comes back.
4781
+ async listTimeProjects(org) {
4782
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
4783
+ return this.get(`/time/projects${qs}`);
4784
+ }
4785
+ async getTimeProject(projectId, org) {
4786
+ const params = new URLSearchParams({ id: projectId });
4787
+ if (org) params.set("org", org);
4788
+ return this.get(`/time/projects?${params.toString()}`);
4789
+ }
4790
+ async upsertTimeProject(payload, org) {
4791
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
4792
+ return this.post(`/time/projects${qs}`, payload);
4793
+ }
4794
+ async listTimeEntries(opts) {
4795
+ const params = new URLSearchParams();
4796
+ if (opts?.project) params.set("project", opts.project);
4797
+ if (opts?.user) params.set("user", opts.user);
4798
+ if (opts?.from) params.set("from", opts.from);
4799
+ if (opts?.to) params.set("to", opts.to);
4800
+ if (opts?.status) params.set("status", opts.status);
4801
+ if (opts?.org) params.set("org", opts.org);
4802
+ const qs = params.toString();
4803
+ return this.get(`/time/entries${qs ? `?${qs}` : ""}`);
4804
+ }
4805
+ async importTimeEntries(input) {
4806
+ const qs = input.org ? `?org=${encodeURIComponent(input.org)}` : "";
4807
+ return this.post(`/time/entries${qs}`, {
4808
+ projectId: input.projectId,
4809
+ dryRun: input.dryRun,
4810
+ entries: input.entries
4811
+ });
4812
+ }
4813
+ async listPendingTimeMonths(org) {
4814
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
4815
+ return this.get(`/time/months${qs}`);
4816
+ }
4817
+ async actOnTimeMonth(input) {
4818
+ const { org, ...body } = input;
4819
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
4820
+ return this.post(`/time/months${qs}`, body);
4821
+ }
4720
4822
  // ── Schema ────────────────────────────────────────────────────────────────
4721
4823
  async getSchema() {
4722
4824
  return this.get("/schema");
@@ -5165,7 +5267,7 @@ var import_promises4 = require("node:readline/promises");
5165
5267
  // package.json
5166
5268
  var package_default = {
5167
5269
  name: "@dayofweek/dcli",
5168
- version: "1.10.0",
5270
+ version: "1.12.0",
5169
5271
  description: "CLI for the Day of Week AgTech platform \u2014 read data and submit proposals for review",
5170
5272
  license: "MIT",
5171
5273
  type: "module",
@@ -5496,8 +5598,21 @@ brainSource.command("download <uri>").description("Download exact original bytes
5496
5598
  }));
5497
5599
  });
5498
5600
  var brainActors = brain.command("actors").description("Work with an area's actors (people and organizations)");
5499
- brainActors.command("list").description("List an area's actors (active, sorted by name)").requiredOption("--area <areaId>", "Area id (from `brain list`)").action(async (opts) => {
5500
- output(await getClient().listBrainActors(opts.area));
5601
+ brainActors.command("list").description("List an area's actors (active, sorted by name)").requiredOption("--area <areaId>", "Area id (from `brain list`)").option("--match", "Suggest matching platform entities for unlinked actors (admins)").action(async (opts) => {
5602
+ output(await getClient().listBrainActors(opts.area, { match: opts.match }));
5603
+ });
5604
+ brainActors.command("link").description("Link an actor to the platform entity it is (or --unlink)").requiredOption("--area <areaId>", "Area id (from `brain list`)").requiredOption("--actor <actorId>", "Actor id (from `brain actors list`)").option("--entity <entityId>", "Platform entity id (from `admin entities` or `read entities`)").option("--unlink", "Remove the current link").action(async (opts) => {
5605
+ if (!opts.entity && !opts.unlink) {
5606
+ throw new Error("Provide --entity <entityId> to link, or --unlink to remove the link");
5607
+ }
5608
+ if (opts.entity && opts.unlink) {
5609
+ throw new Error("--entity and --unlink are mutually exclusive");
5610
+ }
5611
+ output(await getClient().linkBrainActor({
5612
+ areaId: opts.area,
5613
+ actorId: opts.actor,
5614
+ entityId: opts.unlink ? null : opts.entity
5615
+ }));
5501
5616
  });
5502
5617
  brainActors.command("add").description("Add an actor to an area (idempotent on name)").requiredOption("--area <areaId>", "Area id (from `brain list`)").requiredOption("--name <name>", "Actor name (person or organization)").option("--kind <kind>", "person | organization", "organization").option("--role <text>", "Role or relationship in the project").option("--description <text>", "Longer free-text description").action(async (opts) => {
5503
5618
  if (opts.kind !== "person" && opts.kind !== "organization") {
@@ -6150,20 +6265,129 @@ feedback.command("status <itemId>").description("Set status and/or priority on a
6150
6265
  rejectedReason: opts.rejectedReason
6151
6266
  }));
6152
6267
  });
6268
+ var time = program2.command("time").description("Hours per project: projects, entries, monthly sheets");
6269
+ var timeProjects = time.command("projects").description("Time projects: setup and overview");
6270
+ timeProjects.command("list").description("List time projects with totals and frame usage").option("--org <org>", "Organization slug or id (admin only)").action(async (opts) => {
6271
+ output(await getClient().listTimeProjects(opts.org));
6272
+ });
6273
+ timeProjects.command("show <projectId>").description("One project: setup, person\xD7activity\xD7month matrix, entries with computed split, monthly sheets").option("--org <org>", "Organization slug or id (admin only)").action(async (projectId, opts) => {
6274
+ output(await getClient().getTimeProject(projectId, opts.org));
6275
+ });
6276
+ timeProjects.command("upsert").description("Create or update a time project from a JSON file (include projectId to update)").requiredOption("--file <path>", "JSON payload with the project setup (- for stdin)").option("--org <org>", "Organization slug or id (admin only)").action(async (opts) => {
6277
+ const raw = opts.file === "-" ? (0, import_node_fs8.readFileSync)(0, "utf8") : (0, import_node_fs8.readFileSync)(opts.file, "utf8");
6278
+ const payload = JSON.parse(raw);
6279
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
6280
+ throw new Error("Payload must be a JSON object with the project setup");
6281
+ }
6282
+ output(await getClient().upsertTimeProject(payload, opts.org));
6283
+ });
6284
+ var timeEntries = time.command("entries").description("Registered hours");
6285
+ timeEntries.command("list").description("Flat rows with the computed split (counted, self-financed, reportable, reasons)").option("--project <projectId>", "Only this project").option("--user <userId>", "Only this person (owners and admins)").option("--from <YYYY-MM-DD>", "First work date").option("--to <YYYY-MM-DD>", "Last work date").option("--status <status>", "draft | submitted | approved").option("--org <org>", "Organization slug or id (admin only)").action(async (opts) => {
6286
+ output(
6287
+ await getClient().listTimeEntries({
6288
+ project: opts.project,
6289
+ user: opts.user,
6290
+ from: opts.from,
6291
+ to: opts.to,
6292
+ status: opts.status,
6293
+ org: opts.org
6294
+ })
6295
+ );
6296
+ });
6297
+ timeEntries.command("import").description("Idempotent batch import of hours into one project. Requires --dry-run or --approved").requiredOption("--project <projectId>", "Target time project").requiredOption("--file <path>", "JSON: { entries: [...] } or a bare array (- for stdin)").option("--dry-run", "Validate and report what would happen, write nothing").option("--approved", "The operator approved the dry-run result").option("--org <org>", "Organization slug or id (admin only)").action(async (opts) => {
6298
+ if (!opts.dryRun && !opts.approved) {
6299
+ throw new Error(
6300
+ "Refusing to write: run with --dry-run first, show the operator the summary, then repeat with --approved. This writes real hours, not proposals."
6301
+ );
6302
+ }
6303
+ const raw = opts.file === "-" ? (0, import_node_fs8.readFileSync)(0, "utf8") : (0, import_node_fs8.readFileSync)(opts.file, "utf8");
6304
+ const payload = JSON.parse(raw);
6305
+ const entries = Array.isArray(payload) ? payload : payload?.entries;
6306
+ if (!Array.isArray(entries) || entries.length === 0) {
6307
+ throw new Error("Payload needs { entries: [...] } with at least one entry");
6308
+ }
6309
+ output(
6310
+ await getClient().importTimeEntries({
6311
+ projectId: opts.project,
6312
+ entries,
6313
+ dryRun: Boolean(opts.dryRun),
6314
+ org: opts.org
6315
+ })
6316
+ );
6317
+ });
6318
+ var timeMonths = time.command("months").description("Monthly sheets: submit, approve, return, reopen");
6319
+ timeMonths.command("list").description("Months waiting for your approval (project owner), or every submitted month (admin)").option("--org <org>", "Organization slug or id (admin only)").action(async (opts) => {
6320
+ output(await getClient().listPendingTimeMonths(opts.org));
6321
+ });
6322
+ for (const action of ["submit", "approve", "return", "reopen"]) {
6323
+ const descriptions = {
6324
+ submit: "Submit a month for approval (your own; admins may pass --user-email)",
6325
+ approve: "Approve a submitted month \u2014 locks its rows (project owner or admin)",
6326
+ return: "Return a submitted month as drafts, --comment required (project owner or admin)",
6327
+ reopen: "Reopen an approved month for correction; it needs a new approval"
6328
+ };
6329
+ timeMonths.command(action).description(descriptions[action]).requiredOption("--project <projectId>", "Time project").requiredOption("--month <YYYY-MM>", "Month").option("--user-email <email>", "Whose month (defaults to yourself)").option("--user <userId>", "Whose month, by user id").option("--comment <text>", "Comment (required for return)").option("--org <org>", "Organization slug or id (admin only)").action(async (opts) => {
6330
+ if (action === "return" && !opts.comment) {
6331
+ throw new Error("--comment is required when returning a month");
6332
+ }
6333
+ output(
6334
+ await getClient().actOnTimeMonth({
6335
+ projectId: opts.project,
6336
+ month: opts.month,
6337
+ action,
6338
+ userId: opts.user,
6339
+ userEmail: opts.userEmail,
6340
+ comment: opts.comment,
6341
+ org: opts.org
6342
+ })
6343
+ );
6344
+ });
6345
+ }
6153
6346
  function registerAdminCommands() {
6154
6347
  const cfg = loadConfig();
6155
6348
  if (!cfg.isAdmin) return;
6156
6349
  const admin = program2.command("admin", { hidden: true }).description("Admin-only cross-org operations (DoW staff)");
6157
- admin.command("entities").description("List entities across all orgs with admin filters").option("--type <entityType>", "Filter by entity type").option("--missing-location", "Only entities lacking metadata.places[].lat/lng").option("--search <query>", "Substring match on name").option("--org <slug>", "Restrict to a single org slug or ID").option("--limit <count>", "Max results (default 200)", parseInt).action(async (opts) => {
6350
+ admin.command("entities").description("List entities across all orgs with admin filters (cursor-paged)").option("--type <entityType>", "Filter by type name (farm, producer, restaurant, \u2026)").option("--category <category>", "Filter by type category (supply, demand, partner, financial, \u2026)").option("--role <role>", "Only entities holding an active role: customer, partner, investor, producer, market_intel").option("--with-roles", "Attach each entity's active roles").option("--missing-location", "Only entities lacking metadata.places[].lat/lng").option("--search <query>", "Substring match on name").option("--org <slug>", "Restrict to a single org slug or ID").option("--limit <count>", "Rows scanned per page (default 200, max 2000)", parseInt).option("--cursor <cursor>", "Continue from a previous page's nextCursor").option("--all", "Walk every page and return all matches").action(async (opts) => {
6158
6351
  const client = getClient();
6159
- const result = await client.adminListEntities({
6352
+ const fetchPage = (cursor) => client.adminListEntities({
6160
6353
  org: opts.org,
6161
6354
  type: opts.type,
6355
+ category: opts.category,
6356
+ role: opts.role,
6357
+ withRoles: opts.withRoles,
6162
6358
  missingLocation: opts.missingLocation,
6163
6359
  search: opts.search,
6164
- limit: opts.limit
6360
+ limit: opts.limit,
6361
+ cursor
6165
6362
  });
6166
- output(result);
6363
+ output(opts.all ? await collectAdminPages(fetchPage, opts.cursor) : await fetchPage(opts.cursor));
6364
+ });
6365
+ const marketIntel = admin.command("market-intel").description("The market intelligence register \u2014 businesses recorded for research and prospecting");
6366
+ marketIntel.command("list").description("List register rows (cursor-paged, newest first)").option("--type <entityType>", "farm, restaurant, farm_shop, retail_store, distributor, processor, market, cooperative, supplier, customer, competitor, other").option("--relationship <type>", "prospect, active_customer, supplier, partner, competitor, market_research, other").option("--priority <priority>", "critical, high, medium, low, watching").option("--stage <stage>", "Opportunity stage (research, qualified_lead, negotiation, \u2026)").option("--region <region>", "Exact match on region (case-insensitive)").option("--search <query>", "Substring match on name").option("--bbox <latMin,lngMin,latMax,lngMax>", "Only rows inside a lat/lng box").option("--active", "Only active rows").option("--inactive", "Only inactive rows").option("--org <slug>", "Restrict to the owning org").option("--limit <count>", "Rows scanned per page (default 200, max 2000)", parseInt).option("--cursor <cursor>", "Continue from a previous page's nextCursor").option("--all", "Walk every page and return all matches").action(async (opts) => {
6367
+ if (opts.active && opts.inactive) {
6368
+ throw new Error("--active and --inactive are mutually exclusive");
6369
+ }
6370
+ const client = getClient();
6371
+ const fetchPage = (cursor) => client.adminListMarketIntel({
6372
+ org: opts.org,
6373
+ type: opts.type,
6374
+ relationship: opts.relationship,
6375
+ priority: opts.priority,
6376
+ stage: opts.stage,
6377
+ region: opts.region,
6378
+ search: opts.search,
6379
+ bbox: opts.bbox,
6380
+ active: opts.active ? true : opts.inactive ? false : void 0,
6381
+ limit: opts.limit,
6382
+ cursor
6383
+ });
6384
+ output(opts.all ? await collectAdminPages(fetchPage, opts.cursor) : await fetchPage(opts.cursor));
6385
+ });
6386
+ marketIntel.command("get <id>").description("One register record with its latest interactions").action(async (id) => {
6387
+ output(await getClient().adminGetMarketIntel(id));
6388
+ });
6389
+ marketIntel.command("stats").description("Counts by type, relationship, priority, stage, region and country").option("--org <slug>", "Restrict to the owning org").action(async (opts) => {
6390
+ output(await getClient().adminMarketIntelStats(opts.org));
6167
6391
  });
6168
6392
  admin.command("proposals").description("List agent proposals across all orgs").option("--status <status>", "Filter: pending, approved, rejected, failed").option("--source-agent <name>", "Filter by sourceAgent identifier").option("--limit <count>", "Max results (default 100)", parseInt).action(async (opts) => {
6169
6393
  const client = getClient();
package/dist/client.d.ts CHANGED
@@ -13,6 +13,27 @@ export declare class ApiError extends Error {
13
13
  requestId?: string | undefined;
14
14
  constructor(status: number, message: string, code?: string | undefined, requestId?: string | undefined);
15
15
  }
16
+ /**
17
+ * One page of a cursor-paged admin read. Filters apply within the scanned
18
+ * page, so `results` can be shorter than the page size — even empty — while
19
+ * `isDone` is still false. `nextCursor` is the only way forward.
20
+ */
21
+ export type AdminPage<T = any> = {
22
+ results: T[];
23
+ total: number;
24
+ scanned: number;
25
+ isDone: boolean;
26
+ nextCursor: string | null;
27
+ truncated: boolean;
28
+ };
29
+ /**
30
+ * Walk a cursor-paged admin read to the end and return every match as one
31
+ * page. This is the loop behind `--all`; without it every caller re-invents
32
+ * it, or reaches for a giant `--limit` and still misses rows.
33
+ */
34
+ export declare function collectAdminPages<T>(fetchPage: (cursor?: string) => Promise<AdminPage<T>>, startCursor?: string): Promise<AdminPage<T> & {
35
+ pages: number;
36
+ }>;
16
37
  export declare class DayOfWeekClient {
17
38
  private baseUrl;
18
39
  private token;
@@ -146,14 +167,31 @@ export declare class DayOfWeekClient {
146
167
  adminListEntities(opts?: {
147
168
  org?: string;
148
169
  type?: string;
170
+ category?: string;
171
+ role?: string;
172
+ withRoles?: boolean;
149
173
  missingLocation?: boolean;
150
174
  search?: string;
151
175
  limit?: number;
152
- }): Promise<{
153
- results: any[];
154
- truncated: boolean;
155
- total: number;
156
- }>;
176
+ cursor?: string;
177
+ }): Promise<AdminPage>;
178
+ adminListMarketIntel(opts?: {
179
+ org?: string;
180
+ type?: string;
181
+ relationship?: string;
182
+ priority?: string;
183
+ stage?: string;
184
+ region?: string;
185
+ search?: string;
186
+ bbox?: string;
187
+ active?: boolean;
188
+ limit?: number;
189
+ cursor?: string;
190
+ }): Promise<AdminPage>;
191
+ /** One register record in full, with its latest interactions. */
192
+ adminGetMarketIntel(id: string): Promise<any>;
193
+ /** Counts over the whole register — by type, relationship, priority, stage, region, country. */
194
+ adminMarketIntelStats(org?: string): Promise<any>;
157
195
  adminListProposals(opts?: {
158
196
  status?: string;
159
197
  sourceAgent?: string;
@@ -199,8 +237,20 @@ export declare class DayOfWeekClient {
199
237
  }): Promise<any>;
200
238
  /** Area sources, metadata only, newest first — the freshness evidence. */
201
239
  listBrainSources(areaId: string): Promise<any>;
202
- /** An area's actors (people and organizations, the Actors tab). Active only. */
203
- listBrainActors(areaId: string): Promise<any>;
240
+ /**
241
+ * An area's actors (people and organizations, the Actors tab). Active only.
242
+ * With `match`, admins also get `candidates` on unlinked actors — the
243
+ * platform entities whose name matches — to review before linking.
244
+ */
245
+ listBrainActors(areaId: string, opts?: {
246
+ match?: boolean;
247
+ }): Promise<any>;
248
+ /** Link an actor to the platform entity it is; `entityId: null` unlinks. */
249
+ linkBrainActor(input: {
250
+ areaId: string;
251
+ actorId: string;
252
+ entityId: string | null;
253
+ }): Promise<any>;
204
254
  /**
205
255
  * Create an actor in an area. Idempotent on (area, name): an existing active
206
256
  * actor comes back with `created: false` instead of a duplicate, so imports
@@ -424,6 +474,42 @@ export declare class DayOfWeekClient {
424
474
  }>;
425
475
  }): Promise<SharedSkillSummary>;
426
476
  archiveSharedSkill(skillId: string): Promise<SharedSkillSummary>;
477
+ listTimeProjects(org?: string): Promise<{
478
+ projects: any[];
479
+ }>;
480
+ getTimeProject(projectId: string, org?: string): Promise<any>;
481
+ upsertTimeProject(payload: Record<string, unknown>, org?: string): Promise<{
482
+ projectId: string;
483
+ }>;
484
+ listTimeEntries(opts?: {
485
+ project?: string;
486
+ user?: string;
487
+ from?: string;
488
+ to?: string;
489
+ status?: string;
490
+ org?: string;
491
+ }): Promise<{
492
+ total: number;
493
+ rows: any[];
494
+ }>;
495
+ importTimeEntries(input: {
496
+ projectId: string;
497
+ entries: unknown[];
498
+ dryRun: boolean;
499
+ org?: string;
500
+ }): Promise<any>;
501
+ listPendingTimeMonths(org?: string): Promise<{
502
+ approvals: any[];
503
+ }>;
504
+ actOnTimeMonth(input: {
505
+ projectId: string;
506
+ month: string;
507
+ action: "submit" | "approve" | "return" | "reopen";
508
+ userId?: string;
509
+ userEmail?: string;
510
+ comment?: string;
511
+ org?: string;
512
+ }): Promise<any>;
427
513
  getSchema(): Promise<any>;
428
514
  private get;
429
515
  private post;
package/dist/client.js CHANGED
@@ -29,6 +29,27 @@ export class ApiError extends Error {
29
29
  this.name = "ApiError";
30
30
  }
31
31
  }
32
+ /**
33
+ * Walk a cursor-paged admin read to the end and return every match as one
34
+ * page. This is the loop behind `--all`; without it every caller re-invents
35
+ * it, or reaches for a giant `--limit` and still misses rows.
36
+ */
37
+ export async function collectAdminPages(fetchPage, startCursor) {
38
+ const results = [];
39
+ let cursor = startCursor;
40
+ let scanned = 0;
41
+ let pages = 0;
42
+ for (;;) {
43
+ const page = await fetchPage(cursor);
44
+ results.push(...page.results);
45
+ scanned += page.scanned ?? page.results.length;
46
+ pages += 1;
47
+ if (page.isDone || !page.nextCursor)
48
+ break;
49
+ cursor = page.nextCursor;
50
+ }
51
+ return { results, total: results.length, scanned, isDone: true, nextCursor: null, truncated: false, pages };
52
+ }
32
53
  export class DayOfWeekClient {
33
54
  baseUrl;
34
55
  token;
@@ -280,15 +301,61 @@ export class DayOfWeekClient {
280
301
  params.set("org", opts.org);
281
302
  if (opts?.type)
282
303
  params.set("type", opts.type);
304
+ if (opts?.category)
305
+ params.set("category", opts.category);
306
+ if (opts?.role)
307
+ params.set("role", opts.role);
308
+ if (opts?.withRoles)
309
+ params.set("with-roles", "1");
283
310
  if (opts?.missingLocation)
284
311
  params.set("missing-location", "1");
285
312
  if (opts?.search)
286
313
  params.set("search", opts.search);
287
314
  if (opts?.limit)
288
315
  params.set("limit", String(opts.limit));
316
+ if (opts?.cursor)
317
+ params.set("cursor", opts.cursor);
289
318
  const qs = params.toString();
290
319
  return this.get(`/admin/entities${qs ? `?${qs}` : ""}`);
291
320
  }
321
+ // The market intelligence register: businesses recorded for research and
322
+ // prospecting, each with coordinates — a separate table from the entity
323
+ // hierarchy, so a platform-wide count has to read both.
324
+ async adminListMarketIntel(opts) {
325
+ const params = new URLSearchParams();
326
+ if (opts?.org)
327
+ params.set("org", opts.org);
328
+ if (opts?.type)
329
+ params.set("type", opts.type);
330
+ if (opts?.relationship)
331
+ params.set("relationship", opts.relationship);
332
+ if (opts?.priority)
333
+ params.set("priority", opts.priority);
334
+ if (opts?.stage)
335
+ params.set("stage", opts.stage);
336
+ if (opts?.region)
337
+ params.set("region", opts.region);
338
+ if (opts?.search)
339
+ params.set("search", opts.search);
340
+ if (opts?.bbox)
341
+ params.set("bbox", opts.bbox);
342
+ if (opts?.active !== undefined)
343
+ params.set("active", opts.active ? "1" : "0");
344
+ if (opts?.limit)
345
+ params.set("limit", String(opts.limit));
346
+ if (opts?.cursor)
347
+ params.set("cursor", opts.cursor);
348
+ const qs = params.toString();
349
+ return this.get(`/admin/market-intel${qs ? `?${qs}` : ""}`);
350
+ }
351
+ /** One register record in full, with its latest interactions. */
352
+ async adminGetMarketIntel(id) {
353
+ return this.get(`/admin/market-intel/${encodeURIComponent(id)}`);
354
+ }
355
+ /** Counts over the whole register — by type, relationship, priority, stage, region, country. */
356
+ async adminMarketIntelStats(org) {
357
+ return this.get(`/admin/market-intel/stats${org ? `?org=${encodeURIComponent(org)}` : ""}`);
358
+ }
292
359
  async adminListProposals(opts) {
293
360
  const params = new URLSearchParams();
294
361
  if (opts?.status)
@@ -332,9 +399,20 @@ export class DayOfWeekClient {
332
399
  async listBrainSources(areaId) {
333
400
  return this.get(`/brain/sources?area=${encodeURIComponent(areaId)}`);
334
401
  }
335
- /** An area's actors (people and organizations, the Actors tab). Active only. */
336
- async listBrainActors(areaId) {
337
- return this.get(`/brain/actors?area=${encodeURIComponent(areaId)}`);
402
+ /**
403
+ * An area's actors (people and organizations, the Actors tab). Active only.
404
+ * With `match`, admins also get `candidates` on unlinked actors — the
405
+ * platform entities whose name matches — to review before linking.
406
+ */
407
+ async listBrainActors(areaId, opts) {
408
+ const params = new URLSearchParams({ area: areaId });
409
+ if (opts?.match)
410
+ params.set("match", "1");
411
+ return this.get(`/brain/actors?${params.toString()}`);
412
+ }
413
+ /** Link an actor to the platform entity it is; `entityId: null` unlinks. */
414
+ async linkBrainActor(input) {
415
+ return this.post("/brain/actors/link", input);
338
416
  }
339
417
  /**
340
418
  * Create an actor in an area. Idempotent on (area, name): an existing active
@@ -600,6 +678,59 @@ export class DayOfWeekClient {
600
678
  async archiveSharedSkill(skillId) {
601
679
  return this.delete(`/brain/skills/${encodeURIComponent(skillId)}`);
602
680
  }
681
+ // ── Time tracking ─────────────────────────────────────────────────────────
682
+ //
683
+ // Hours registered against time projects. Every rule (periods, frames,
684
+ // self-financing, the reportable basis, who may approve) is evaluated by
685
+ // the server; the client only carries JSON and prints what comes back.
686
+ async listTimeProjects(org) {
687
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
688
+ return this.get(`/time/projects${qs}`);
689
+ }
690
+ async getTimeProject(projectId, org) {
691
+ const params = new URLSearchParams({ id: projectId });
692
+ if (org)
693
+ params.set("org", org);
694
+ return this.get(`/time/projects?${params.toString()}`);
695
+ }
696
+ async upsertTimeProject(payload, org) {
697
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
698
+ return this.post(`/time/projects${qs}`, payload);
699
+ }
700
+ async listTimeEntries(opts) {
701
+ const params = new URLSearchParams();
702
+ if (opts?.project)
703
+ params.set("project", opts.project);
704
+ if (opts?.user)
705
+ params.set("user", opts.user);
706
+ if (opts?.from)
707
+ params.set("from", opts.from);
708
+ if (opts?.to)
709
+ params.set("to", opts.to);
710
+ if (opts?.status)
711
+ params.set("status", opts.status);
712
+ if (opts?.org)
713
+ params.set("org", opts.org);
714
+ const qs = params.toString();
715
+ return this.get(`/time/entries${qs ? `?${qs}` : ""}`);
716
+ }
717
+ async importTimeEntries(input) {
718
+ const qs = input.org ? `?org=${encodeURIComponent(input.org)}` : "";
719
+ return this.post(`/time/entries${qs}`, {
720
+ projectId: input.projectId,
721
+ dryRun: input.dryRun,
722
+ entries: input.entries,
723
+ });
724
+ }
725
+ async listPendingTimeMonths(org) {
726
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
727
+ return this.get(`/time/months${qs}`);
728
+ }
729
+ async actOnTimeMonth(input) {
730
+ const { org, ...body } = input;
731
+ const qs = org ? `?org=${encodeURIComponent(org)}` : "";
732
+ return this.post(`/time/months${qs}`, body);
733
+ }
603
734
  // ── Schema ────────────────────────────────────────────────────────────────
604
735
  async getSchema() {
605
736
  return this.get("/schema");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dayofweek/dcli",
3
- "version": "1.10.0",
4
- "description": "CLI for the Day of Week AgTech platform \u2014 read data and submit proposals for review",
3
+ "version": "1.12.0",
4
+ "description": "CLI for the Day of Week AgTech platform read data and submit proposals for review",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {