Add credits, projects, tasks, vendors, and purchase orders
Five more resources following clients.rs's template exactly, same as the previous batch. credits.rs is otherwise identical to quotes.rs since Credit is also a type alias for Invoice in the SDK (same table, distinguished by entity_type upstream). Verified against a live local server: full create/list/get/update/ delete round trip for all five. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+9
-8
@@ -12,14 +12,15 @@ command line.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Six resources, each with `list` (with `--all` for every page, `--status`,
|
- Eleven resources, each with `list` (with `--all` for every page,
|
||||||
and `--param key=value` for anything `ListParams` doesn't have a typed
|
`--status`, and `--param key=value` for anything `ListParams` doesn't have
|
||||||
setter for), `get`, `create`, `update`, and `delete`: `clients`,
|
a typed setter for), `get`, `create`, `update`, and `delete`: `clients`,
|
||||||
`invoices`, `quotes`, `payments`, `products`, `expenses`. `quotes` is
|
`invoices`, `quotes`, `credits`, `payments`, `products`, `expenses`,
|
||||||
`invoices` under a different endpoint — Invoice Ninja stores both in the
|
`projects`, `tasks`, `vendors`, `purchase-orders`. `quotes` and `credits`
|
||||||
same table, distinguished by `entity_type`, so `Quote` is a type alias for
|
are `invoices` under different endpoints — Invoice Ninja stores all three
|
||||||
`Invoice` in the SDK and `commands/quotes.rs` is otherwise identical to
|
in the same table, distinguished by `entity_type`, so `Quote` and `Credit`
|
||||||
`commands/invoices.rs`.
|
are type aliases for `Invoice` in the SDK and `commands/quotes.rs` /
|
||||||
|
`commands/credits.rs` are otherwise identical to `commands/invoices.rs`.
|
||||||
- `update` fetches the current record, shallow-merges the caller's
|
- `update` fetches the current record, shallow-merges the caller's
|
||||||
`--data`/`--file` JSON on top, and writes the merged result back, rather
|
`--data`/`--file` JSON on top, and writes the merged result back, rather
|
||||||
than sending the patch alone — Invoice Ninja's `PUT` is a full replace.
|
than sending the patch alone — Invoice Ninja's `PUT` is a full replace.
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ design call, the same way its counterpart in the SDK works. If a change
|
|||||||
would contradict a stated goal or settle an open question, update
|
would contradict a stated goal or settle an open question, update
|
||||||
`GOALS.md` in the same change.
|
`GOALS.md` in the same change.
|
||||||
|
|
||||||
**Early scaffold.** Six resources are wired up (`clients`, `invoices`,
|
**Early scaffold.** Eleven resources are wired up (`clients`, `invoices`,
|
||||||
`quotes`, `payments`, `products`, `expenses`) out of ~34; `clients` was the
|
`quotes`, `credits`, `payments`, `products`, `expenses`, `projects`,
|
||||||
template the other five followed. See "Adding a resource" below before
|
`tasks`, `vendors`, `purchase_orders`) out of ~34; `clients` was the
|
||||||
|
template the other ten followed. See "Adding a resource" below before
|
||||||
writing the next one.
|
writing the next one.
|
||||||
|
|
||||||
## Development environment
|
## Development environment
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
|
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
|
||||||
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
|
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
|
||||||
|
|
||||||
> **Early scaffold.** `clients`, `invoices`, `quotes`, `payments`, `products`,
|
> **Early scaffold.** `clients`, `invoices`, `quotes`, `credits`, `payments`,
|
||||||
> and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md`
|
> `products`, `expenses`, `projects`, `tasks`, `vendors`, and
|
||||||
> for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the
|
> `purchase-orders` are wired up so far, out of ~34 resources — see
|
||||||
> crate is for and what it is not.
|
> `CLAUDE.md` for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers
|
||||||
|
> what the crate is for and what it is not.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ table view's few key columns:
|
|||||||
invoiceninja-cli --output json clients get <id>
|
invoiceninja-cli --output json clients get <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
Every other wired-up resource (`invoices`, `quotes`, `payments`, `products`,
|
Every other wired-up resource (`invoices`, `quotes`, `credits`, `payments`,
|
||||||
`expenses`) follows the same `list`/`get`/`create`/`update`/`delete` shape,
|
`products`, `expenses`, `projects`, `tasks`, `vendors`,
|
||||||
e.g. `invoiceninja-cli invoices list --status active` or
|
`purchase-orders`) follows the same `list`/`get`/`create`/`update`/`delete`
|
||||||
`invoiceninja-cli payments get <id>`.
|
shape, e.g. `invoiceninja-cli invoices list --status active` or
|
||||||
|
`invoiceninja-cli purchase-orders get <id>`.
|
||||||
|
|||||||
+12
-2
@@ -45,9 +45,9 @@ pub enum OutputFormat {
|
|||||||
Json,
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One variant per resource. Six are wired up so far (`clients` was the
|
/// One variant per resource. Eleven are wired up so far (`clients` was the
|
||||||
/// template for the rest); see CLAUDE.md's "Adding a resource" for the
|
/// template for the rest); see CLAUDE.md's "Adding a resource" for the
|
||||||
/// remaining ~28.
|
/// remaining ~23.
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Manage clients
|
/// Manage clients
|
||||||
@@ -56,12 +56,22 @@ pub enum Command {
|
|||||||
Invoices(ResourceArgs),
|
Invoices(ResourceArgs),
|
||||||
/// Manage quotes
|
/// Manage quotes
|
||||||
Quotes(ResourceArgs),
|
Quotes(ResourceArgs),
|
||||||
|
/// Manage credits
|
||||||
|
Credits(ResourceArgs),
|
||||||
/// Manage payments
|
/// Manage payments
|
||||||
Payments(ResourceArgs),
|
Payments(ResourceArgs),
|
||||||
/// Manage products
|
/// Manage products
|
||||||
Products(ResourceArgs),
|
Products(ResourceArgs),
|
||||||
/// Manage expenses
|
/// Manage expenses
|
||||||
Expenses(ResourceArgs),
|
Expenses(ResourceArgs),
|
||||||
|
/// Manage projects
|
||||||
|
Projects(ResourceArgs),
|
||||||
|
/// Manage tasks
|
||||||
|
Tasks(ResourceArgs),
|
||||||
|
/// Manage vendors
|
||||||
|
Vendors(ResourceArgs),
|
||||||
|
/// Manage purchase orders
|
||||||
|
PurchaseOrders(ResourceArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
//! `credits` subcommand. Same shape as `clients.rs` — see the "Adding a
|
||||||
|
//! resource" section in CLAUDE.md. `Credit` is a type alias for `Invoice`
|
||||||
|
//! (Invoice Ninja stores credits in the same table/schema, distinguished by
|
||||||
|
//! an `entity_type`), same as `quotes.rs`.
|
||||||
|
|
||||||
|
use invoiceninja_sdk::models::{Credit, ListParams};
|
||||||
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
|
use crate::body;
|
||||||
|
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ResourceArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match args.action {
|
||||||
|
Action::List(list_args) => list(client, list_args, format).await,
|
||||||
|
Action::Get { id } => get(client, &id, format).await,
|
||||||
|
Action::Create(body_args) => create(client, body_args, format).await,
|
||||||
|
Action::Update { id, body } => update(client, &id, body, format).await,
|
||||||
|
Action::Delete { id } => delete(client, &id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ListArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut params = ListParams::new();
|
||||||
|
if let Some(page) = args.page {
|
||||||
|
params = params.page(page);
|
||||||
|
}
|
||||||
|
if let Some(per_page) = args.per_page {
|
||||||
|
params = params.per_page(per_page);
|
||||||
|
}
|
||||||
|
if let Some(sort) = args.sort {
|
||||||
|
params = params.sort(sort);
|
||||||
|
}
|
||||||
|
if let Some(filter) = args.filter {
|
||||||
|
params = params.filter(filter);
|
||||||
|
}
|
||||||
|
if let Some(status) = args.status {
|
||||||
|
params = params.status(status);
|
||||||
|
}
|
||||||
|
if let Some(include) = args.include {
|
||||||
|
params = params.include(include);
|
||||||
|
}
|
||||||
|
for (k, v) in args.params {
|
||||||
|
params = params.param(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let credits = if args.all {
|
||||||
|
client.list_all_credits(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_credits(¶ms).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
render(&credits, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let found = client.get_credit(id).await?;
|
||||||
|
render(std::slice::from_ref(&found), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let value = body::load(&body_args)?;
|
||||||
|
let model: Credit = serde_json::from_value(value)?;
|
||||||
|
let created = client.create_credit(&model).await?;
|
||||||
|
render(std::slice::from_ref(&created), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let patch = body::load(&body_args)?;
|
||||||
|
let current = client.get_credit(id).await?;
|
||||||
|
let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch);
|
||||||
|
let model: Credit = serde_json::from_value(merged)?;
|
||||||
|
let updated = client.update_credit(id, &model).await?;
|
||||||
|
render(std::slice::from_ref(&updated), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
client.delete_credit(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(credits: &[Credit], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Json => output::print_json(credits)?,
|
||||||
|
OutputFormat::Table => print_table(credits),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_table(credits: &[Credit]) {
|
||||||
|
if credits.is_empty() {
|
||||||
|
println!("(no records)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
|
||||||
|
"id", "number", "client_id", "status", "amount", "balance"
|
||||||
|
);
|
||||||
|
for c in credits {
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
|
||||||
|
c.id.as_deref().unwrap_or(""),
|
||||||
|
truncate(&c.number, 15),
|
||||||
|
truncate(&c.client_id, 20),
|
||||||
|
c.status_id,
|
||||||
|
c.amount,
|
||||||
|
c.balance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod credits;
|
||||||
pub mod expenses;
|
pub mod expenses;
|
||||||
pub mod invoices;
|
pub mod invoices;
|
||||||
pub mod payments;
|
pub mod payments;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
|
pub mod projects;
|
||||||
|
pub mod purchase_orders;
|
||||||
pub mod quotes;
|
pub mod quotes;
|
||||||
|
pub mod tasks;
|
||||||
|
pub mod vendors;
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! `projects` subcommand. Same shape as `clients.rs` — see the "Adding a
|
||||||
|
//! resource" section in CLAUDE.md.
|
||||||
|
|
||||||
|
use invoiceninja_sdk::models::{ListParams, Project};
|
||||||
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
|
use crate::body;
|
||||||
|
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ResourceArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match args.action {
|
||||||
|
Action::List(list_args) => list(client, list_args, format).await,
|
||||||
|
Action::Get { id } => get(client, &id, format).await,
|
||||||
|
Action::Create(body_args) => create(client, body_args, format).await,
|
||||||
|
Action::Update { id, body } => update(client, &id, body, format).await,
|
||||||
|
Action::Delete { id } => delete(client, &id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ListArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut params = ListParams::new();
|
||||||
|
if let Some(page) = args.page {
|
||||||
|
params = params.page(page);
|
||||||
|
}
|
||||||
|
if let Some(per_page) = args.per_page {
|
||||||
|
params = params.per_page(per_page);
|
||||||
|
}
|
||||||
|
if let Some(sort) = args.sort {
|
||||||
|
params = params.sort(sort);
|
||||||
|
}
|
||||||
|
if let Some(filter) = args.filter {
|
||||||
|
params = params.filter(filter);
|
||||||
|
}
|
||||||
|
if let Some(status) = args.status {
|
||||||
|
params = params.status(status);
|
||||||
|
}
|
||||||
|
if let Some(include) = args.include {
|
||||||
|
params = params.include(include);
|
||||||
|
}
|
||||||
|
for (k, v) in args.params {
|
||||||
|
params = params.param(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projects = if args.all {
|
||||||
|
client.list_all_projects(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_projects(¶ms).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
render(&projects, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let found = client.get_project(id).await?;
|
||||||
|
render(std::slice::from_ref(&found), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let value = body::load(&body_args)?;
|
||||||
|
let model: Project = serde_json::from_value(value)?;
|
||||||
|
let created = client.create_project(&model).await?;
|
||||||
|
render(std::slice::from_ref(&created), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let patch = body::load(&body_args)?;
|
||||||
|
let current = client.get_project(id).await?;
|
||||||
|
let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch);
|
||||||
|
let model: Project = serde_json::from_value(merged)?;
|
||||||
|
let updated = client.update_project(id, &model).await?;
|
||||||
|
render(std::slice::from_ref(&updated), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
client.delete_project(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(projects: &[Project], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Json => output::print_json(projects)?,
|
||||||
|
OutputFormat::Table => print_table(projects),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_table(projects: &[Project]) {
|
||||||
|
if projects.is_empty() {
|
||||||
|
println!("(no records)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<30} {:<20} {:<10} {:>14}",
|
||||||
|
"id", "name", "client_id", "number", "budgeted_hours"
|
||||||
|
);
|
||||||
|
for p in projects {
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<30} {:<20} {:<10} {:>14.2}",
|
||||||
|
p.id.as_deref().unwrap_or(""),
|
||||||
|
truncate(&p.name, 30),
|
||||||
|
truncate(&p.client_id, 20),
|
||||||
|
p.number,
|
||||||
|
p.budgeted_hours
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! `purchase-orders` subcommand. Same shape as `clients.rs` — see the
|
||||||
|
//! "Adding a resource" section in CLAUDE.md.
|
||||||
|
|
||||||
|
use invoiceninja_sdk::models::{ListParams, PurchaseOrder};
|
||||||
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
|
use crate::body;
|
||||||
|
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ResourceArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match args.action {
|
||||||
|
Action::List(list_args) => list(client, list_args, format).await,
|
||||||
|
Action::Get { id } => get(client, &id, format).await,
|
||||||
|
Action::Create(body_args) => create(client, body_args, format).await,
|
||||||
|
Action::Update { id, body } => update(client, &id, body, format).await,
|
||||||
|
Action::Delete { id } => delete(client, &id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ListArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut params = ListParams::new();
|
||||||
|
if let Some(page) = args.page {
|
||||||
|
params = params.page(page);
|
||||||
|
}
|
||||||
|
if let Some(per_page) = args.per_page {
|
||||||
|
params = params.per_page(per_page);
|
||||||
|
}
|
||||||
|
if let Some(sort) = args.sort {
|
||||||
|
params = params.sort(sort);
|
||||||
|
}
|
||||||
|
if let Some(filter) = args.filter {
|
||||||
|
params = params.filter(filter);
|
||||||
|
}
|
||||||
|
if let Some(status) = args.status {
|
||||||
|
params = params.status(status);
|
||||||
|
}
|
||||||
|
if let Some(include) = args.include {
|
||||||
|
params = params.include(include);
|
||||||
|
}
|
||||||
|
for (k, v) in args.params {
|
||||||
|
params = params.param(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let purchase_orders = if args.all {
|
||||||
|
client.list_all_purchase_orders(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_purchase_orders(¶ms).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
render(&purchase_orders, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let found = client.get_purchase_order(id).await?;
|
||||||
|
render(std::slice::from_ref(&found), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let value = body::load(&body_args)?;
|
||||||
|
let model: PurchaseOrder = serde_json::from_value(value)?;
|
||||||
|
let created = client.create_purchase_order(&model).await?;
|
||||||
|
render(std::slice::from_ref(&created), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let patch = body::load(&body_args)?;
|
||||||
|
let current = client.get_purchase_order(id).await?;
|
||||||
|
let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch);
|
||||||
|
let model: PurchaseOrder = serde_json::from_value(merged)?;
|
||||||
|
let updated = client.update_purchase_order(id, &model).await?;
|
||||||
|
render(std::slice::from_ref(&updated), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
client.delete_purchase_order(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(
|
||||||
|
purchase_orders: &[PurchaseOrder],
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Json => output::print_json(purchase_orders)?,
|
||||||
|
OutputFormat::Table => print_table(purchase_orders),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_table(purchase_orders: &[PurchaseOrder]) {
|
||||||
|
if purchase_orders.is_empty() {
|
||||||
|
println!("(no records)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
|
||||||
|
"id", "number", "vendor_id", "status", "amount", "balance"
|
||||||
|
);
|
||||||
|
for po in purchase_orders {
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
|
||||||
|
po.id.as_deref().unwrap_or(""),
|
||||||
|
truncate(&po.number, 15),
|
||||||
|
truncate(&po.vendor_id, 20),
|
||||||
|
po.status_id,
|
||||||
|
po.amount,
|
||||||
|
po.balance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! `tasks` subcommand. Same shape as `clients.rs` — see the "Adding a
|
||||||
|
//! resource" section in CLAUDE.md.
|
||||||
|
|
||||||
|
use invoiceninja_sdk::models::{ListParams, Task};
|
||||||
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
|
use crate::body;
|
||||||
|
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ResourceArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match args.action {
|
||||||
|
Action::List(list_args) => list(client, list_args, format).await,
|
||||||
|
Action::Get { id } => get(client, &id, format).await,
|
||||||
|
Action::Create(body_args) => create(client, body_args, format).await,
|
||||||
|
Action::Update { id, body } => update(client, &id, body, format).await,
|
||||||
|
Action::Delete { id } => delete(client, &id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ListArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut params = ListParams::new();
|
||||||
|
if let Some(page) = args.page {
|
||||||
|
params = params.page(page);
|
||||||
|
}
|
||||||
|
if let Some(per_page) = args.per_page {
|
||||||
|
params = params.per_page(per_page);
|
||||||
|
}
|
||||||
|
if let Some(sort) = args.sort {
|
||||||
|
params = params.sort(sort);
|
||||||
|
}
|
||||||
|
if let Some(filter) = args.filter {
|
||||||
|
params = params.filter(filter);
|
||||||
|
}
|
||||||
|
if let Some(status) = args.status {
|
||||||
|
params = params.status(status);
|
||||||
|
}
|
||||||
|
if let Some(include) = args.include {
|
||||||
|
params = params.include(include);
|
||||||
|
}
|
||||||
|
for (k, v) in args.params {
|
||||||
|
params = params.param(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tasks = if args.all {
|
||||||
|
client.list_all_tasks(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_tasks(¶ms).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
render(&tasks, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let found = client.get_task(id).await?;
|
||||||
|
render(std::slice::from_ref(&found), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let value = body::load(&body_args)?;
|
||||||
|
let model: Task = serde_json::from_value(value)?;
|
||||||
|
let created = client.create_task(&model).await?;
|
||||||
|
render(std::slice::from_ref(&created), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let patch = body::load(&body_args)?;
|
||||||
|
let current = client.get_task(id).await?;
|
||||||
|
let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch);
|
||||||
|
let model: Task = serde_json::from_value(merged)?;
|
||||||
|
let updated = client.update_task(id, &model).await?;
|
||||||
|
render(std::slice::from_ref(&updated), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
client.delete_task(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(tasks: &[Task], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Json => output::print_json(tasks)?,
|
||||||
|
OutputFormat::Table => print_table(tasks),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_table(tasks: &[Task]) {
|
||||||
|
if tasks.is_empty() {
|
||||||
|
println!("(no records)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<30} {:<20} {:<20} {:<8}",
|
||||||
|
"id", "description", "client_id", "project_id", "status"
|
||||||
|
);
|
||||||
|
for t in tasks {
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<30} {:<20} {:<20} {:<8}",
|
||||||
|
t.id.as_deref().unwrap_or(""),
|
||||||
|
truncate(&t.description, 30),
|
||||||
|
truncate(&t.client_id, 20),
|
||||||
|
truncate(&t.project_id, 20),
|
||||||
|
t.status_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//! `vendors` subcommand. Same shape as `clients.rs` — see the "Adding a
|
||||||
|
//! resource" section in CLAUDE.md.
|
||||||
|
|
||||||
|
use invoiceninja_sdk::models::{ListParams, Vendor};
|
||||||
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
|
use crate::body;
|
||||||
|
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
||||||
|
use crate::output;
|
||||||
|
|
||||||
|
pub async fn run(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ResourceArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match args.action {
|
||||||
|
Action::List(list_args) => list(client, list_args, format).await,
|
||||||
|
Action::Get { id } => get(client, &id, format).await,
|
||||||
|
Action::Create(body_args) => create(client, body_args, format).await,
|
||||||
|
Action::Update { id, body } => update(client, &id, body, format).await,
|
||||||
|
Action::Delete { id } => delete(client, &id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
args: ListArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut params = ListParams::new();
|
||||||
|
if let Some(page) = args.page {
|
||||||
|
params = params.page(page);
|
||||||
|
}
|
||||||
|
if let Some(per_page) = args.per_page {
|
||||||
|
params = params.per_page(per_page);
|
||||||
|
}
|
||||||
|
if let Some(sort) = args.sort {
|
||||||
|
params = params.sort(sort);
|
||||||
|
}
|
||||||
|
if let Some(filter) = args.filter {
|
||||||
|
params = params.filter(filter);
|
||||||
|
}
|
||||||
|
if let Some(status) = args.status {
|
||||||
|
params = params.status(status);
|
||||||
|
}
|
||||||
|
if let Some(include) = args.include {
|
||||||
|
params = params.include(include);
|
||||||
|
}
|
||||||
|
for (k, v) in args.params {
|
||||||
|
params = params.param(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vendors = if args.all {
|
||||||
|
client.list_all_vendors(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_vendors(¶ms).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
render(&vendors, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let found = client.get_vendor(id).await?;
|
||||||
|
render(std::slice::from_ref(&found), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let value = body::load(&body_args)?;
|
||||||
|
let model: Vendor = serde_json::from_value(value)?;
|
||||||
|
let created = client.create_vendor(&model).await?;
|
||||||
|
render(std::slice::from_ref(&created), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
body_args: BodyArgs,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let patch = body::load(&body_args)?;
|
||||||
|
let current = client.get_vendor(id).await?;
|
||||||
|
let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch);
|
||||||
|
let model: Vendor = serde_json::from_value(merged)?;
|
||||||
|
let updated = client.update_vendor(id, &model).await?;
|
||||||
|
render(std::slice::from_ref(&updated), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
client.delete_vendor(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(vendors: &[Vendor], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Json => output::print_json(vendors)?,
|
||||||
|
OutputFormat::Table => print_table(vendors),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_table(vendors: &[Vendor]) {
|
||||||
|
if vendors.is_empty() {
|
||||||
|
println!("(no records)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<30} {:<20} {:<10}",
|
||||||
|
"id", "name", "city", "country_id"
|
||||||
|
);
|
||||||
|
for v in vendors {
|
||||||
|
println!(
|
||||||
|
"{:<20} {:<30} {:<20} {:<10}",
|
||||||
|
v.id.as_deref().unwrap_or(""),
|
||||||
|
truncate(&v.name, 30),
|
||||||
|
truncate(&v.city, 20),
|
||||||
|
v.country_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
|
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
|
||||||
Command::Invoices(args) => commands::invoices::run(&client, args, cli.output).await,
|
Command::Invoices(args) => commands::invoices::run(&client, args, cli.output).await,
|
||||||
Command::Quotes(args) => commands::quotes::run(&client, args, cli.output).await,
|
Command::Quotes(args) => commands::quotes::run(&client, args, cli.output).await,
|
||||||
|
Command::Credits(args) => commands::credits::run(&client, args, cli.output).await,
|
||||||
Command::Payments(args) => commands::payments::run(&client, args, cli.output).await,
|
Command::Payments(args) => commands::payments::run(&client, args, cli.output).await,
|
||||||
Command::Products(args) => commands::products::run(&client, args, cli.output).await,
|
Command::Products(args) => commands::products::run(&client, args, cli.output).await,
|
||||||
Command::Expenses(args) => commands::expenses::run(&client, args, cli.output).await,
|
Command::Expenses(args) => commands::expenses::run(&client, args, cli.output).await,
|
||||||
|
Command::Projects(args) => commands::projects::run(&client, args, cli.output).await,
|
||||||
|
Command::Tasks(args) => commands::tasks::run(&client, args, cli.output).await,
|
||||||
|
Command::Vendors(args) => commands::vendors::run(&client, args, cli.output).await,
|
||||||
|
Command::PurchaseOrders(args) => {
|
||||||
|
commands::purchase_orders::run(&client, args, cli.output).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user