mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
1 Commits
main
...
fix/issues
| Author | SHA1 | Date | |
|---|---|---|---|
| 5976a03ce0 |
@@ -27,6 +27,7 @@
|
|||||||
"@types/luxon": "^3.7.1",
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/node": "^22.10.3",
|
"@types/node": "^22.10.3",
|
||||||
"@types/passport-apple": "^2.0.3",
|
"@types/passport-apple": "^2.0.3",
|
||||||
|
"@types/pdfmake": "^0.3.3",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.5.8",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"passport-apple": "^2.0.2",
|
"passport-apple": "^2.0.2",
|
||||||
"passport-github2": "^0.1.12",
|
"passport-github2": "^0.1.12",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"pdfmake": "^0.3.11",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-boss": "^12.14.0",
|
"pg-boss": "^12.14.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
|
|||||||
@@ -780,5 +780,25 @@
|
|||||||
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
|
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
|
||||||
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
|
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"61": {
|
||||||
|
"version": "1.66.0",
|
||||||
|
"name": "Billing: currencies, sellers, clients, invoices",
|
||||||
|
"releaseDate": "20260904",
|
||||||
|
"scripts": [
|
||||||
|
"/1.66.0/0.create-currencies.sql",
|
||||||
|
"/1.66.0/1.create-billing-sellers.sql",
|
||||||
|
"/1.66.0/2.create-billing-counterparties.sql",
|
||||||
|
"/1.66.0/3.create-invoices.sql",
|
||||||
|
"/1.66.0/4.create-invoice-lines.sql",
|
||||||
|
"/1.66.0/5.billing-permission.sql",
|
||||||
|
"/1.66.0/6.alter-invoices-lifecycle.sql"
|
||||||
|
],
|
||||||
|
"description": [
|
||||||
|
"New schema tv_billing with the reference table currencies (ISO 4217, seeded with 20 currencies), sellers (the organization's own companies that issue invoices, with bank details and free-form requisites), counterparties (clients that are invoiced) and invoices with invoice_lines.",
|
||||||
|
"Invoices keep JSONB snapshots of the seller and the client plus the project name, so editing a company, a client or deleting a project never changes an issued document. Companies and clients are archived rather than deleted once they have invoices.",
|
||||||
|
"New organization-level permission billing_can_manage (group 6) narrows what an API or OAuth token may do on the billing surfaces; access itself requires the organization owner or admin role.",
|
||||||
|
"Invoice lifecycle: issued_at, paid_at, voided_at, replaces_invoice_id (void and reissue chain), template_version and totals frozen at issue time (subtotal, discount_amount, tax_amount, total). Status transitions are validated by the API; only drafts can be edited or deleted."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Reference list of currencies (ISO 4217) for anything that carries money:
|
||||||
|
-- invoices, counterparties, seller profiles. Names are stored in English only;
|
||||||
|
-- the UI localises them through Intl.DisplayNames by code. decimal_digits
|
||||||
|
-- drives amount formatting (JPY and KRW have none). Rows are never deleted,
|
||||||
|
-- only deactivated, so existing references stay valid.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS tv_billing;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_billing.currencies (
|
||||||
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
code CHAR(3) NOT NULL UNIQUE,
|
||||||
|
numeric_code SMALLINT NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
symbol VARCHAR(8) NOT NULL,
|
||||||
|
decimal_digits SMALLINT NOT NULL DEFAULT 2,
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT currencies_code_upper_check CHECK (code = UPPER(code)),
|
||||||
|
CONSTRAINT currencies_decimal_digits_check CHECK (decimal_digits BETWEEN 0 AND 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_currencies_active_sort ON tv_billing.currencies(is_active, sort_order);
|
||||||
|
|
||||||
|
INSERT INTO tv_billing.currencies (code, numeric_code, name, symbol, decimal_digits, sort_order) VALUES
|
||||||
|
('USD', 840, 'US Dollar', '$', 2, 10),
|
||||||
|
('EUR', 978, 'Euro', '€', 2, 20),
|
||||||
|
('GBP', 826, 'Pound Sterling', '£', 2, 30),
|
||||||
|
('JPY', 392, 'Japanese Yen', '¥', 0, 40),
|
||||||
|
('CNY', 156, 'Chinese Yuan', '¥', 2, 50),
|
||||||
|
('CHF', 756, 'Swiss Franc', 'CHF', 2, 60),
|
||||||
|
('CAD', 124, 'Canadian Dollar', 'CA$', 2, 70),
|
||||||
|
('AUD', 36, 'Australian Dollar', 'A$', 2, 80),
|
||||||
|
('RUB', 643, 'Russian Ruble', '₽', 2, 25),
|
||||||
|
('INR', 356, 'Indian Rupee', '₹', 2, 100),
|
||||||
|
('BRL', 986, 'Brazilian Real', 'R$', 2, 110),
|
||||||
|
('KRW', 410, 'South Korean Won', '₩', 0, 120),
|
||||||
|
('SGD', 702, 'Singapore Dollar', 'S$', 2, 130),
|
||||||
|
('HKD', 344, 'Hong Kong Dollar', 'HK$', 2, 140),
|
||||||
|
('SEK', 752, 'Swedish Krona', 'kr', 2, 150),
|
||||||
|
('NOK', 578, 'Norwegian Krone', 'kr', 2, 160),
|
||||||
|
('DKK', 208, 'Danish Krone', 'kr', 2, 170),
|
||||||
|
('PLN', 985, 'Polish Zloty', 'zł', 2, 180),
|
||||||
|
('TRY', 949, 'Turkish Lira', '₺', 2, 190),
|
||||||
|
('AED', 784, 'UAE Dirham', 'د.إ', 2, 200)
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tv_billing.sellers (
|
||||||
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
legal_name VARCHAR(300) NOT NULL DEFAULT '',
|
||||||
|
address VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
email VARCHAR(320) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
logo_url VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'USD' REFERENCES tv_billing.currencies(code),
|
||||||
|
bank JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
default_terms VARCHAR(2000) NOT NULL DEFAULT '',
|
||||||
|
tax_note VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_sellers_org ON tv_billing.sellers(organization_id, archived);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tv_billing.counterparties (
|
||||||
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||||
|
kind VARCHAR(20) NOT NULL DEFAULT 'organization',
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
legal_name VARCHAR(300) NOT NULL DEFAULT '',
|
||||||
|
address VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
email VARCHAR(320) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
contact_person VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT counterparties_kind_check CHECK (kind IN ('organization', 'person'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_counterparties_org ON tv_billing.counterparties(organization_id, archived);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tv_billing.invoices (
|
||||||
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
|
||||||
|
goal_id INTEGER REFERENCES tasks.goals(id) ON DELETE SET NULL,
|
||||||
|
goal_name VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
seller_id INTEGER NOT NULL REFERENCES tv_billing.sellers(id) ON DELETE RESTRICT,
|
||||||
|
counterparty_id INTEGER NOT NULL REFERENCES tv_billing.counterparties(id) ON DELETE RESTRICT,
|
||||||
|
number VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(10) NOT NULL DEFAULT 'draft',
|
||||||
|
reference VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
currency_code CHAR(3) NOT NULL REFERENCES tv_billing.currencies(code),
|
||||||
|
issue_date DATE NOT NULL,
|
||||||
|
payment_terms VARCHAR(20) NOT NULL DEFAULT 'net14',
|
||||||
|
due_date DATE,
|
||||||
|
period_from DATE,
|
||||||
|
period_to DATE,
|
||||||
|
discount_type VARCHAR(10) NOT NULL DEFAULT 'percent',
|
||||||
|
discount_value NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax_exempt BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
tax_note VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
notes VARCHAR(2000) NOT NULL DEFAULT '',
|
||||||
|
terms VARCHAR(2000) NOT NULL DEFAULT '',
|
||||||
|
seller_snapshot JSONB NOT NULL,
|
||||||
|
counterparty_snapshot JSONB NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT invoices_status_check CHECK (status IN ('draft', 'issued', 'paid', 'void')),
|
||||||
|
CONSTRAINT invoices_discount_type_check CHECK (discount_type IN ('percent', 'amount')),
|
||||||
|
CONSTRAINT invoices_payment_terms_check CHECK (payment_terms IN ('on_receipt', 'net7', 'net14', 'net30', 'custom')),
|
||||||
|
CONSTRAINT invoices_number_per_org UNIQUE (organization_id, number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_invoices_org_issue ON tv_billing.invoices(organization_id, issue_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_invoices_counterparty ON tv_billing.invoices(counterparty_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_invoices_seller ON tv_billing.invoices(seller_id);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tv_billing.invoice_lines (
|
||||||
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES tv_billing.invoices(id) ON DELETE CASCADE,
|
||||||
|
position SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
|
||||||
|
description VARCHAR(1000) NOT NULL,
|
||||||
|
unit VARCHAR(20) NOT NULL DEFAULT 'service',
|
||||||
|
quantity NUMERIC(12, 2) NOT NULL DEFAULT 1,
|
||||||
|
unit_price NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT invoice_lines_unit_check CHECK (unit IN ('service', 'hours', 'pcs'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_invoice_lines_invoice ON tv_billing.invoice_lines(invoice_id, position);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||||
|
VALUES (
|
||||||
|
'billing_can_manage',
|
||||||
|
'Manage invoices, clients and seller companies of the organization',
|
||||||
|
6,
|
||||||
|
'{
|
||||||
|
"en": "Manage billing. Create and edit invoices, clients and seller companies of the organization.",
|
||||||
|
"ru": "Управление биллингом. Создавать и редактировать инвойсы, клиентов и компании-продавцы организации.",
|
||||||
|
"de": "Abrechnung verwalten. Rechnungen, Kunden und Firmen der Organisation erstellen und bearbeiten.",
|
||||||
|
"es": "Gestionar facturación. Crear y editar facturas, clientes y empresas de la organización.",
|
||||||
|
"pt-BR": "Gerenciar faturamento. Criar e editar faturas, clientes e empresas da organização."
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS issued_at TIMESTAMP;
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMP;
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS voided_at TIMESTAMP;
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS replaces_invoice_id INTEGER REFERENCES tv_billing.invoices(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS template_version SMALLINT NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS subtotal NUMERIC(12, 2);
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS discount_amount NUMERIC(12, 2);
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(12, 2);
|
||||||
|
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS total NUMERIC(12, 2);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_invoices_replaces ON tv_billing.invoices(replaces_invoice_id);
|
||||||
@@ -24,6 +24,8 @@ import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
|
|||||||
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
|
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
|
||||||
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
|
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
|
||||||
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
|
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
|
||||||
|
import BillingRoutes from '../tv-modules/billing/BillingRoutes';
|
||||||
|
import InvoicesRoutes from '../tv-modules/invoices/InvoicesRoutes';
|
||||||
import type { Routable } from '../types/routable.type';
|
import type { Routable } from '../types/routable.type';
|
||||||
|
|
||||||
type RoutableConstructor = new (...args: any[]) => Routable;
|
type RoutableConstructor = new (...args: any[]) => Routable;
|
||||||
@@ -53,6 +55,8 @@ const routes: Record<string, RoutableConstructor> = {
|
|||||||
'/module/ui-preferences': UiPreferencesRoutes,
|
'/module/ui-preferences': UiPreferencesRoutes,
|
||||||
'/module/sprints': SprintsRoutes,
|
'/module/sprints': SprintsRoutes,
|
||||||
'/module/recurrence': RecurrenceRoutes,
|
'/module/recurrence': RecurrenceRoutes,
|
||||||
|
'/module/billing': BillingRoutes,
|
||||||
|
'/module/invoices': InvoicesRoutes,
|
||||||
'/scim/v2': ScimRoutes,
|
'/scim/v2': ScimRoutes,
|
||||||
'/.well-known': OAuthWellKnownRoutes,
|
'/.well-known': OAuthWellKnownRoutes,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { ArkErrors } from 'arktype'
|
||||||
|
import { BillingManager } from './BillingManager'
|
||||||
|
import {
|
||||||
|
BillingArkTypeArchive,
|
||||||
|
BillingArkTypeId,
|
||||||
|
BillingArkTypeList,
|
||||||
|
CounterpartyArkTypeCreate,
|
||||||
|
CounterpartyArkTypeUpdate,
|
||||||
|
SellerArkTypeCreate,
|
||||||
|
SellerArkTypeUpdate,
|
||||||
|
type DeleteResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const DELETE_STATUS: Record<DeleteResult, number> = { deleted: 200, in_use: 409, not_found: 404 }
|
||||||
|
|
||||||
|
export class BillingController {
|
||||||
|
private readonly manager = new BillingManager()
|
||||||
|
|
||||||
|
currencies = async (_req: Request, res: Response) => {
|
||||||
|
return res.tvJson(await this.manager.fetchCurrencies())
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSellers = async (req: Request, res: Response) => {
|
||||||
|
const data = BillingArkTypeList(req.query)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
return res.tvJson(await this.manager.fetchSellers(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
createSeller = async (req: Request, res: Response) => {
|
||||||
|
const data = SellerArkTypeCreate(req.body)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.createSeller(data)
|
||||||
|
if (!result) return res.status(500).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSeller = async (req: Request, res: Response) => {
|
||||||
|
const id = BillingArkTypeId(req.params)
|
||||||
|
const data = SellerArkTypeUpdate(req.body)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.updateSeller({ sellerId: id.id, data })
|
||||||
|
if (!result) return res.status(404).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveSeller = async (req: Request, res: Response) => {
|
||||||
|
const id = BillingArkTypeId(req.params)
|
||||||
|
const data = BillingArkTypeArchive(req.body)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.setSellerArchived({ id: id.id, archived: data.archived })
|
||||||
|
if (!result) return res.status(404).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSeller = async (req: Request, res: Response) => {
|
||||||
|
const id = BillingArkTypeId(req.params)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
const result = await this.manager.deleteSeller(id.id)
|
||||||
|
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
|
||||||
|
return res.tvJson(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCounterparties = async (req: Request, res: Response) => {
|
||||||
|
const data = BillingArkTypeList(req.query)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
return res.tvJson(await this.manager.fetchCounterparties(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
createCounterparty = async (req: Request, res: Response) => {
|
||||||
|
const data = CounterpartyArkTypeCreate(req.body)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.createCounterparty(data)
|
||||||
|
if (!result) return res.status(500).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCounterparty = async (req: Request, res: Response) => {
|
||||||
|
const id = BillingArkTypeId(req.params)
|
||||||
|
const data = CounterpartyArkTypeUpdate(req.body)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.updateCounterparty({ counterpartyId: id.id, data })
|
||||||
|
if (!result) return res.status(404).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveCounterparty = async (req: Request, res: Response) => {
|
||||||
|
const id = BillingArkTypeId(req.params)
|
||||||
|
const data = BillingArkTypeArchive(req.body)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.setCounterpartyArchived({ id: id.id, archived: data.archived })
|
||||||
|
if (!result) return res.status(404).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCounterparty = async (req: Request, res: Response) => {
|
||||||
|
const id = BillingArkTypeId(req.params)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
const result = await this.manager.deleteCounterparty(id.id)
|
||||||
|
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
|
||||||
|
return res.tvJson(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { BillingRepository } from './BillingRepository'
|
||||||
|
import type {
|
||||||
|
BillingArgList,
|
||||||
|
CounterpartyArgCreate,
|
||||||
|
CounterpartyForClient,
|
||||||
|
CounterpartyUpdateArgs,
|
||||||
|
DeleteResult,
|
||||||
|
SellerArgCreate,
|
||||||
|
SellerForClient,
|
||||||
|
SellerUpdateArgs,
|
||||||
|
SetArchivedArgs,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export class BillingManager {
|
||||||
|
public readonly repository: BillingRepository
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = new BillingRepository()
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCurrencies() {
|
||||||
|
return this.repository.fetchCurrencies()
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSellers(args: BillingArgList): Promise<SellerForClient[]> {
|
||||||
|
return this.repository.fetchSellers(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSellerById(sellerId: number): Promise<SellerForClient | null> {
|
||||||
|
return this.repository.fetchSellerById(sellerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSeller(data: SellerArgCreate): Promise<SellerForClient | null> {
|
||||||
|
return this.repository.createSeller(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSeller(args: SellerUpdateArgs): Promise<SellerForClient | null> {
|
||||||
|
return this.repository.updateSeller(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSellerArchived(args: SetArchivedArgs): Promise<SellerForClient | null> {
|
||||||
|
return this.repository.setSellerArchived(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSeller(sellerId: number): Promise<DeleteResult> {
|
||||||
|
if ((await this.repository.countInvoicesBySeller(sellerId)) > 0) return 'in_use'
|
||||||
|
return (await this.repository.deleteSeller(sellerId)) ? 'deleted' : 'not_found'
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCounterparties(args: BillingArgList): Promise<CounterpartyForClient[]> {
|
||||||
|
return this.repository.fetchCounterparties(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCounterpartyById(counterpartyId: number): Promise<CounterpartyForClient | null> {
|
||||||
|
return this.repository.fetchCounterpartyById(counterpartyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartyForClient | null> {
|
||||||
|
return this.repository.createCounterparty(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCounterparty(args: CounterpartyUpdateArgs): Promise<CounterpartyForClient | null> {
|
||||||
|
return this.repository.updateCounterparty(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCounterpartyArchived(args: SetArchivedArgs): Promise<CounterpartyForClient | null> {
|
||||||
|
return this.repository.setCounterpartyArchived(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCounterparty(counterpartyId: number): Promise<DeleteResult> {
|
||||||
|
if ((await this.repository.countInvoicesByCounterparty(counterpartyId)) > 0) return 'in_use'
|
||||||
|
return (await this.repository.deleteCounterparty(counterpartyId)) ? 'deleted' : 'not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { and, asc, count, eq } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
CounterpartiesSchema,
|
||||||
|
CurrenciesSchema,
|
||||||
|
InvoicesSchema,
|
||||||
|
SellersSchema,
|
||||||
|
type CounterpartiesSchemaTypeForSelect,
|
||||||
|
type CurrenciesSchemaTypeForSelect,
|
||||||
|
type SellersSchemaTypeForSelect,
|
||||||
|
} from 'taskview-db-schemas'
|
||||||
|
import { Database } from '../../modules/db'
|
||||||
|
import { callWithCatch } from '../../utils/helpers'
|
||||||
|
import type {
|
||||||
|
BillingArgList,
|
||||||
|
CounterpartyArgCreate,
|
||||||
|
CounterpartyArgUpdate,
|
||||||
|
CounterpartyUpdateArgs,
|
||||||
|
SellerArgCreate,
|
||||||
|
SellerArgUpdate,
|
||||||
|
SellerUpdateArgs,
|
||||||
|
SetArchivedArgs,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export class BillingRepository {
|
||||||
|
private readonly db: Database
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.db = Database.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCurrencies(): Promise<CurrenciesSchemaTypeForSelect[]> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select()
|
||||||
|
.from(CurrenciesSchema)
|
||||||
|
.where(eq(CurrenciesSchema.isActive, true))
|
||||||
|
.orderBy(asc(CurrenciesSchema.sortOrder)),
|
||||||
|
)
|
||||||
|
return result ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSellers({ organizationId, includeArchived }: BillingArgList): Promise<SellersSchemaTypeForSelect[]> {
|
||||||
|
const conditions = [eq(SellersSchema.organizationId, organizationId)]
|
||||||
|
if (!includeArchived) conditions.push(eq(SellersSchema.archived, false))
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.select().from(SellersSchema).where(and(...conditions)).orderBy(asc(SellersSchema.name)),
|
||||||
|
)
|
||||||
|
return result ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSellerById(sellerId: number): Promise<SellersSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.select().from(SellersSchema).where(eq(SellersSchema.id, sellerId)),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private sellerColumns(data: SellerArgUpdate) {
|
||||||
|
return {
|
||||||
|
name: data.name,
|
||||||
|
legalName: data.legalName,
|
||||||
|
address: data.address,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
logoUrl: data.logoUrl,
|
||||||
|
currencyCode: data.currencyCode,
|
||||||
|
bank: data.bank,
|
||||||
|
requisites: data.requisites,
|
||||||
|
defaultTerms: data.defaultTerms,
|
||||||
|
taxNote: data.taxNote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private counterpartyColumns(data: CounterpartyArgUpdate) {
|
||||||
|
return {
|
||||||
|
kind: data.kind,
|
||||||
|
name: data.name,
|
||||||
|
legalName: data.legalName,
|
||||||
|
address: data.address,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
contactPerson: data.contactPerson,
|
||||||
|
requisites: data.requisites,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSeller(data: SellerArgCreate): Promise<SellersSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.insert(SellersSchema)
|
||||||
|
.values({ organizationId: data.organizationId, ...this.sellerColumns(data) })
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSeller({ sellerId, data }: SellerUpdateArgs): Promise<SellersSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.update(SellersSchema)
|
||||||
|
.set({ ...this.sellerColumns(data), updatedAt: new Date() })
|
||||||
|
.where(eq(SellersSchema.id, sellerId))
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSellerArchived({ id, archived }: SetArchivedArgs): Promise<SellersSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.update(SellersSchema)
|
||||||
|
.set({ archived, updatedAt: new Date() })
|
||||||
|
.where(eq(SellersSchema.id, id))
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSeller(sellerId: number): Promise<boolean> {
|
||||||
|
const result = await callWithCatch(() => this.db.dbDrizzle.delete(SellersSchema).where(eq(SellersSchema.id, sellerId)))
|
||||||
|
return !!result?.rowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
async countInvoicesBySeller(sellerId: number): Promise<number> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.select({ total: count() }).from(InvoicesSchema).where(eq(InvoicesSchema.sellerId, sellerId)),
|
||||||
|
)
|
||||||
|
return result?.[0]?.total ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCounterparties({ organizationId, includeArchived }: BillingArgList): Promise<CounterpartiesSchemaTypeForSelect[]> {
|
||||||
|
const conditions = [eq(CounterpartiesSchema.organizationId, organizationId)]
|
||||||
|
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select()
|
||||||
|
.from(CounterpartiesSchema)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(CounterpartiesSchema.name)),
|
||||||
|
)
|
||||||
|
return result ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCounterpartyById(counterpartyId: number): Promise<CounterpartiesSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.select().from(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartiesSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.insert(CounterpartiesSchema)
|
||||||
|
.values({ organizationId: data.organizationId, ...this.counterpartyColumns(data) })
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCounterparty({ counterpartyId, data }: CounterpartyUpdateArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.update(CounterpartiesSchema)
|
||||||
|
.set({ ...this.counterpartyColumns(data), updatedAt: new Date() })
|
||||||
|
.where(eq(CounterpartiesSchema.id, counterpartyId))
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCounterpartyArchived({ id, archived }: SetArchivedArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.update(CounterpartiesSchema)
|
||||||
|
.set({ archived, updatedAt: new Date() })
|
||||||
|
.where(eq(CounterpartiesSchema.id, id))
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCounterparty(counterpartyId: number): Promise<boolean> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.delete(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
|
||||||
|
)
|
||||||
|
return !!result?.rowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
async countInvoicesByCounterparty(counterpartyId: number): Promise<number> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(InvoicesSchema)
|
||||||
|
.where(eq(InvoicesSchema.counterpartyId, counterpartyId)),
|
||||||
|
)
|
||||||
|
return result?.[0]?.total ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import type { Routable } from '../../types/routable.type'
|
||||||
|
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||||
|
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
|
||||||
|
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
|
||||||
|
import { GoalPermissions } from '../../types/auth.types'
|
||||||
|
import { BillingController } from './BillingController'
|
||||||
|
import { isOrgAdminForCounterparty, isOrgAdminForSeller } from './middlewares/is-org-admin-for-billing'
|
||||||
|
|
||||||
|
export default class BillingRoutes implements Routable {
|
||||||
|
private readonly router: ReturnType<typeof Router>
|
||||||
|
private readonly controller: BillingController
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router = Router()
|
||||||
|
this.controller = new BillingController()
|
||||||
|
this.initRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
getRouter() {
|
||||||
|
return this.router
|
||||||
|
}
|
||||||
|
|
||||||
|
private initRoutes() {
|
||||||
|
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
|
||||||
|
|
||||||
|
this.router.get('/currencies', [IsLoggedIn], this.controller.currencies)
|
||||||
|
|
||||||
|
this.router.get('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchSellers)
|
||||||
|
this.router.post('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createSeller)
|
||||||
|
this.router.patch('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.updateSeller)
|
||||||
|
this.router.patch('/sellers/:id/archive', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.archiveSeller)
|
||||||
|
this.router.delete('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.deleteSeller)
|
||||||
|
|
||||||
|
this.router.get('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchCounterparties)
|
||||||
|
this.router.post('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createCounterparty)
|
||||||
|
this.router.patch('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.updateCounterparty)
|
||||||
|
this.router.patch('/counterparties/:id/archive', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.archiveCounterparty)
|
||||||
|
this.router.delete('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.deleteCounterparty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { BillingRepository } from '../BillingRepository'
|
||||||
|
import { isOrgAdminFor } from './is-org-admin-for'
|
||||||
|
|
||||||
|
const repository = new BillingRepository()
|
||||||
|
|
||||||
|
export const isOrgAdminForSeller = isOrgAdminFor(async (id) => (await repository.fetchSellerById(id))?.organizationId ?? null)
|
||||||
|
|
||||||
|
export const isOrgAdminForCounterparty = isOrgAdminFor(
|
||||||
|
async (id) => (await repository.fetchCounterpartyById(id))?.organizationId ?? null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
import { ORG_ADMIN_ROLES, type OrgRole } from '../../organizations/types'
|
||||||
|
import { parsePositiveInt } from '../../../utils/helpers'
|
||||||
|
|
||||||
|
export type OrganizationIdResolver = (id: number) => Promise<number | null>
|
||||||
|
|
||||||
|
export const isOrgAdminFor = (resolveOrganizationId: OrganizationIdResolver) => {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const id = parsePositiveInt(req.params.id)
|
||||||
|
if (id === null) return res.status(400).end()
|
||||||
|
|
||||||
|
const organizationId = await resolveOrganizationId(id)
|
||||||
|
if (organizationId === null) return res.status(404).end()
|
||||||
|
|
||||||
|
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
|
||||||
|
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) return res.status(403).end()
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { type } from 'arktype'
|
||||||
|
import type {
|
||||||
|
BillingBankDetails,
|
||||||
|
BillingRequisite,
|
||||||
|
CounterpartiesSchemaTypeForSelect,
|
||||||
|
SellersSchemaTypeForSelect,
|
||||||
|
} from 'taskview-db-schemas'
|
||||||
|
|
||||||
|
const NumberFromString = type('string|number').pipe((v) => Number(v))
|
||||||
|
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
|
||||||
|
if (v === undefined) return undefined
|
||||||
|
if (typeof v === 'boolean') return v
|
||||||
|
return v === 'true' || v === '1'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const RequisiteArkType = type({
|
||||||
|
key: 'string<=64',
|
||||||
|
label: 'string<=100',
|
||||||
|
value: 'string<=300',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BankDetailsArkType = type({
|
||||||
|
bankName: 'string<=200',
|
||||||
|
accountNumber: 'string<=64',
|
||||||
|
iban: 'string<=64',
|
||||||
|
swift: 'string<=32',
|
||||||
|
correspondentAccount: 'string<=64',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SellerArkTypeCreate = type({
|
||||||
|
organizationId: 'number',
|
||||||
|
name: '1<=string<=200',
|
||||||
|
legalName: 'string<=300',
|
||||||
|
address: 'string<=1000',
|
||||||
|
email: 'string<=320',
|
||||||
|
phone: 'string<=50',
|
||||||
|
logoUrl: 'string<=1000',
|
||||||
|
currencyCode: /^[A-Z]{3}$/,
|
||||||
|
bank: BankDetailsArkType,
|
||||||
|
requisites: RequisiteArkType.array(),
|
||||||
|
defaultTerms: 'string<=2000',
|
||||||
|
taxNote: 'string<=500',
|
||||||
|
})
|
||||||
|
export type SellerArgCreate = typeof SellerArkTypeCreate.infer
|
||||||
|
|
||||||
|
export const SellerArkTypeUpdate = SellerArkTypeCreate.omit('organizationId')
|
||||||
|
export type SellerArgUpdate = typeof SellerArkTypeUpdate.infer
|
||||||
|
|
||||||
|
export const CounterpartyArkTypeCreate = type({
|
||||||
|
organizationId: 'number',
|
||||||
|
kind: "'organization' | 'person'",
|
||||||
|
name: '1<=string<=200',
|
||||||
|
legalName: 'string<=300',
|
||||||
|
address: 'string<=1000',
|
||||||
|
email: 'string<=320',
|
||||||
|
phone: 'string<=50',
|
||||||
|
contactPerson: 'string<=200',
|
||||||
|
requisites: RequisiteArkType.array(),
|
||||||
|
})
|
||||||
|
export type CounterpartyArgCreate = typeof CounterpartyArkTypeCreate.infer
|
||||||
|
|
||||||
|
export const CounterpartyArkTypeUpdate = CounterpartyArkTypeCreate.omit('organizationId')
|
||||||
|
export type CounterpartyArgUpdate = typeof CounterpartyArkTypeUpdate.infer
|
||||||
|
|
||||||
|
export const BillingArkTypeList = type({
|
||||||
|
organizationId: NumberFromString,
|
||||||
|
'includeArchived?': BooleanFromString,
|
||||||
|
})
|
||||||
|
export type BillingArgList = typeof BillingArkTypeList.infer
|
||||||
|
|
||||||
|
export const BillingArkTypeId = type({
|
||||||
|
id: NumberFromString,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BillingArkTypeArchive = type({
|
||||||
|
archived: 'boolean',
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SellerUpdateArgs = {
|
||||||
|
sellerId: number
|
||||||
|
data: SellerArgUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartyUpdateArgs = {
|
||||||
|
counterpartyId: number
|
||||||
|
data: CounterpartyArgUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetArchivedArgs = {
|
||||||
|
id: number
|
||||||
|
archived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SellerForClient = Omit<SellersSchemaTypeForSelect, 'bank' | 'requisites'> & {
|
||||||
|
bank: BillingBankDetails
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartyForClient = Omit<CounterpartiesSchemaTypeForSelect, 'requisites'> & {
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteResult = 'deleted' | 'in_use' | 'not_found'
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import pdfmakeModule from 'pdfmake'
|
||||||
|
import vfs from 'pdfmake/build/vfs_fonts.js'
|
||||||
|
import { invoiceLineAmount } from '../../utils/invoiceTotals'
|
||||||
|
import type { InvoiceForClient, InvoicePdfLang, PdfmakeServer } from './types'
|
||||||
|
|
||||||
|
const LABELS: Record<InvoicePdfLang, Record<string, string>> = {
|
||||||
|
en: {
|
||||||
|
title: 'INVOICE',
|
||||||
|
void: 'VOID',
|
||||||
|
issueDate: 'Issue date',
|
||||||
|
dueDate: 'Due date',
|
||||||
|
reference: 'Reference',
|
||||||
|
period: 'Period',
|
||||||
|
replaces: 'Replaces',
|
||||||
|
billTo: 'Bill to',
|
||||||
|
description: 'Description',
|
||||||
|
qty: 'Qty',
|
||||||
|
unit: 'Unit',
|
||||||
|
price: 'Price',
|
||||||
|
amount: 'Amount',
|
||||||
|
subtotal: 'Subtotal',
|
||||||
|
discount: 'Discount',
|
||||||
|
tax: 'Tax',
|
||||||
|
total: 'Total',
|
||||||
|
paymentDetails: 'Payment details',
|
||||||
|
bankName: 'Bank',
|
||||||
|
accountNumber: 'Account',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / BIC',
|
||||||
|
correspondentAccount: 'Correspondent account',
|
||||||
|
service: 'service',
|
||||||
|
hours: 'h',
|
||||||
|
pcs: 'pcs',
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
title: 'СЧЁТ',
|
||||||
|
void: 'АННУЛИРОВАН',
|
||||||
|
issueDate: 'Дата выставления',
|
||||||
|
dueDate: 'Срок оплаты',
|
||||||
|
reference: 'Основание',
|
||||||
|
period: 'Период',
|
||||||
|
replaces: 'Взамен',
|
||||||
|
billTo: 'Плательщик',
|
||||||
|
description: 'Описание',
|
||||||
|
qty: 'Кол-во',
|
||||||
|
unit: 'Ед.',
|
||||||
|
price: 'Цена',
|
||||||
|
amount: 'Сумма',
|
||||||
|
subtotal: 'Промежуточная сумма',
|
||||||
|
discount: 'Скидка',
|
||||||
|
tax: 'Налог',
|
||||||
|
total: 'Итого',
|
||||||
|
paymentDetails: 'Реквизиты для оплаты',
|
||||||
|
bankName: 'Банк',
|
||||||
|
accountNumber: 'Расчётный счёт',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / БИК',
|
||||||
|
correspondentAccount: 'Корр. счёт',
|
||||||
|
service: 'усл.',
|
||||||
|
hours: 'ч',
|
||||||
|
pcs: 'шт.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_FILES = ['Roboto-Regular.ttf', 'Roboto-Medium.ttf', 'Roboto-Italic.ttf', 'Roboto-MediumItalic.ttf']
|
||||||
|
const MUTED = '#6b7280'
|
||||||
|
const RULE = '#d4d4d8'
|
||||||
|
|
||||||
|
export class InvoicePdfRenderer {
|
||||||
|
private static fontsReady = false
|
||||||
|
private readonly pdfmake = pdfmakeModule as unknown as PdfmakeServer
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!InvoicePdfRenderer.fontsReady) {
|
||||||
|
for (const name of FONT_FILES) this.pdfmake.virtualfs.writeFileSync(name, Buffer.from(vfs[name], 'base64'))
|
||||||
|
this.pdfmake.setFonts({
|
||||||
|
Roboto: {
|
||||||
|
normal: 'Roboto-Regular.ttf',
|
||||||
|
bold: 'Roboto-Medium.ttf',
|
||||||
|
italics: 'Roboto-Italic.ttf',
|
||||||
|
bolditalics: 'Roboto-MediumItalic.ttf',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
InvoicePdfRenderer.fontsReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(invoice: InvoiceForClient, lang: InvoicePdfLang): Promise<Buffer> {
|
||||||
|
return this.pdfmake.createPdf(this.buildDocument(invoice, lang)).getBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDocument(invoice: InvoiceForClient, lang: InvoicePdfLang) {
|
||||||
|
const t = LABELS[lang]
|
||||||
|
const money = (amount: number) => this.formatMoney(amount, invoice.currencyCode, lang)
|
||||||
|
const date = (value: string | null) => this.formatDate(value, lang)
|
||||||
|
|
||||||
|
const metaRows: [string, string][] = [[t.issueDate, date(invoice.issueDate)]]
|
||||||
|
if (invoice.dueDate) metaRows.push([t.dueDate, date(invoice.dueDate)])
|
||||||
|
if (invoice.reference) metaRows.push([t.reference, invoice.reference])
|
||||||
|
if (invoice.periodFrom || invoice.periodTo) metaRows.push([t.period, `${date(invoice.periodFrom)} – ${date(invoice.periodTo)}`])
|
||||||
|
|
||||||
|
const lineRows = invoice.lines.map((line) => [
|
||||||
|
{ text: line.description },
|
||||||
|
{ text: String(line.quantity), alignment: 'right' },
|
||||||
|
{ text: t[line.unit] ?? line.unit, color: MUTED },
|
||||||
|
{ text: money(line.unitPrice), alignment: 'right' },
|
||||||
|
{ text: money(invoiceLineAmount(line.quantity, line.unitPrice)), alignment: 'right' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const totalsRows: unknown[] = [[{ text: t.subtotal, color: MUTED }, { text: money(invoice.totals.subtotal), alignment: 'right' }]]
|
||||||
|
if (invoice.totals.discount > 0) totalsRows.push([{ text: t.discount, color: MUTED }, { text: `−${money(invoice.totals.discount)}`, alignment: 'right' }])
|
||||||
|
if (!invoice.taxExempt) totalsRows.push([{ text: `${t.tax} ${invoice.taxRate}%`, color: MUTED }, { text: money(invoice.totals.tax), alignment: 'right' }])
|
||||||
|
totalsRows.push([{ text: t.total, bold: true, fontSize: 12 }, { text: money(invoice.totals.total), bold: true, fontSize: 12, alignment: 'right' }])
|
||||||
|
|
||||||
|
const bank = invoice.seller.bank
|
||||||
|
const bankRows = (
|
||||||
|
[
|
||||||
|
[t.bankName, bank.bankName],
|
||||||
|
[t.accountNumber, bank.accountNumber],
|
||||||
|
[t.iban, bank.iban],
|
||||||
|
[t.swift, bank.swift],
|
||||||
|
[t.correspondentAccount, bank.correspondentAccount],
|
||||||
|
] as [string, string][]
|
||||||
|
).filter(([, value]) => value.trim().length > 0)
|
||||||
|
|
||||||
|
const content: unknown[] = [
|
||||||
|
{
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
width: '*',
|
||||||
|
stack: [
|
||||||
|
{ text: invoice.seller.legalName || invoice.seller.name, fontSize: 14, bold: true },
|
||||||
|
...this.partyLines(invoice.seller),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 'auto',
|
||||||
|
stack: [
|
||||||
|
{ text: t.title, fontSize: 20, bold: true, alignment: 'right' },
|
||||||
|
{ text: invoice.number, alignment: 'right', margin: [0, 2, 0, 8] },
|
||||||
|
{
|
||||||
|
table: { body: metaRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value, alignment: 'right' }]) },
|
||||||
|
layout: 'noBorders',
|
||||||
|
fontSize: 9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columnGap: 24,
|
||||||
|
},
|
||||||
|
{ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: RULE }], margin: [0, 12, 0, 12] },
|
||||||
|
{ text: t.billTo.toUpperCase(), fontSize: 8, bold: true, color: MUTED },
|
||||||
|
{ text: invoice.counterparty.legalName || invoice.counterparty.name, bold: true, margin: [0, 2, 0, 0] },
|
||||||
|
...(invoice.counterparty.contactPerson ? [{ text: invoice.counterparty.contactPerson }] : []),
|
||||||
|
...this.partyLines(invoice.counterparty),
|
||||||
|
{
|
||||||
|
table: {
|
||||||
|
headerRows: 1,
|
||||||
|
widths: ['*', 40, 44, 80, 90],
|
||||||
|
body: [
|
||||||
|
[
|
||||||
|
{ text: t.description.toUpperCase(), style: 'th' },
|
||||||
|
{ text: t.qty.toUpperCase(), style: 'th', alignment: 'right' },
|
||||||
|
{ text: t.unit.toUpperCase(), style: 'th' },
|
||||||
|
{ text: t.price.toUpperCase(), style: 'th', alignment: 'right' },
|
||||||
|
{ text: t.amount.toUpperCase(), style: 'th', alignment: 'right' },
|
||||||
|
],
|
||||||
|
...lineRows,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
hLineWidth: (index: number, node: { table: { body: unknown[] } }) => (index === 0 || index === node.table.body.length ? 0 : 0.5),
|
||||||
|
vLineWidth: () => 0,
|
||||||
|
hLineColor: () => RULE,
|
||||||
|
paddingTop: () => 6,
|
||||||
|
paddingBottom: () => 6,
|
||||||
|
},
|
||||||
|
margin: [0, 18, 0, 8],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columns: [
|
||||||
|
{ width: '*', text: '' },
|
||||||
|
{ width: 220, table: { widths: ['*', 'auto'], body: totalsRows }, layout: 'noBorders' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (bankRows.length > 0) {
|
||||||
|
content.push(
|
||||||
|
{ text: t.paymentDetails.toUpperCase(), fontSize: 8, bold: true, color: MUTED, margin: [0, 20, 0, 4] },
|
||||||
|
{ table: { body: bankRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value }]) }, layout: 'noBorders' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (invoice.taxExempt && invoice.taxNote) content.push({ text: invoice.taxNote, margin: [0, 16, 0, 0], color: '#374151' })
|
||||||
|
if (invoice.terms) content.push({ text: invoice.terms, margin: [0, 8, 0, 0], color: '#374151' })
|
||||||
|
if (invoice.notes) content.push({ text: invoice.notes, margin: [0, 8, 0, 0], color: '#374151' })
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageSize: 'A4',
|
||||||
|
pageMargins: [40, 40, 40, 40],
|
||||||
|
info: { title: invoice.number },
|
||||||
|
...(invoice.status === 'void' ? { watermark: { text: t.void, color: '#ef4444', opacity: 0.12, bold: true } } : {}),
|
||||||
|
defaultStyle: { font: 'Roboto', fontSize: 10, color: '#18181b' },
|
||||||
|
styles: { th: { fontSize: 8, bold: true, color: MUTED } },
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private partyLines(party: InvoiceForClient['seller'] | InvoiceForClient['counterparty']) {
|
||||||
|
const lines: unknown[] = []
|
||||||
|
if (party.address) lines.push({ text: party.address, color: MUTED, fontSize: 9 })
|
||||||
|
for (const item of party.requisites) {
|
||||||
|
if (item.label.trim() && item.value.trim()) lines.push({ text: `${item.label}: ${item.value}`, color: MUTED, fontSize: 9 })
|
||||||
|
}
|
||||||
|
const contacts = [party.email, party.phone].filter(Boolean).join(' · ')
|
||||||
|
if (contacts) lines.push({ text: contacts, color: MUTED, fontSize: 9 })
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatMoney(amount: number, currencyCode: string, lang: InvoicePdfLang): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(lang === 'ru' ? 'ru-RU' : 'en-US', { style: 'currency', currency: currencyCode }).format(amount)
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currencyCode}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(value: string | null, lang: InvoicePdfLang): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const [year, month, day] = value.split('-')
|
||||||
|
return lang === 'ru' ? `${day}.${month}.${year}` : `${day}.${month}.${year}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { ArkErrors } from 'arktype'
|
||||||
|
import { InvoicesManager } from './InvoicesManager'
|
||||||
|
import {
|
||||||
|
InvoiceArkTypeCreate,
|
||||||
|
InvoiceArkTypeId,
|
||||||
|
InvoiceArkTypeList,
|
||||||
|
InvoiceArkTypePdf,
|
||||||
|
InvoiceArkTypeStatus,
|
||||||
|
InvoiceArkTypeUpdate,
|
||||||
|
type InvoiceDeleteResult,
|
||||||
|
type InvoiceTransitionResult,
|
||||||
|
type InvoiceWriteError,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const WRITE_ERROR_STATUS: Record<InvoiceWriteError, number> = {
|
||||||
|
seller_not_found: 422,
|
||||||
|
counterparty_not_found: 422,
|
||||||
|
goal_not_found: 422,
|
||||||
|
duplicate_number: 409,
|
||||||
|
not_found: 404,
|
||||||
|
not_draft: 409,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSITION_ERROR_STATUS = { not_found: 404, invalid_transition: 409, missing_requisites: 422 } as const
|
||||||
|
|
||||||
|
const DELETE_STATUS: Record<InvoiceDeleteResult, number> = { deleted: 200, not_draft: 409, not_found: 404 }
|
||||||
|
|
||||||
|
export class InvoicesController {
|
||||||
|
private readonly manager = new InvoicesManager()
|
||||||
|
|
||||||
|
fetch = async (req: Request, res: Response) => {
|
||||||
|
const data = InvoiceArkTypeList(req.query)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
return res.tvJson(await this.manager.fetchList(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
getById = async (req: Request, res: Response) => {
|
||||||
|
const id = InvoiceArkTypeId(req.params)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
const result = await this.manager.fetchById(id.id)
|
||||||
|
if (!result) return res.status(404).end()
|
||||||
|
return res.tvJson(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
create = async (req: Request, res: Response) => {
|
||||||
|
const data = InvoiceArkTypeCreate(req.body)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.create({ data, createdBy: req.appUser.getUserData()?.id ?? null })
|
||||||
|
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
|
||||||
|
return res.tvJson(result.invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (req: Request, res: Response) => {
|
||||||
|
const id = InvoiceArkTypeId(req.params)
|
||||||
|
const data = InvoiceArkTypeUpdate(req.body)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const result = await this.manager.update({ invoiceId: id.id, data })
|
||||||
|
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
|
||||||
|
return res.tvJson(result.invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus = async (req: Request, res: Response) => {
|
||||||
|
const id = InvoiceArkTypeId(req.params)
|
||||||
|
const data = InvoiceArkTypeStatus(req.body)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
return this.sendTransition(res, await this.manager.transition({ invoiceId: id.id, status: data.status }))
|
||||||
|
}
|
||||||
|
|
||||||
|
reissue = async (req: Request, res: Response) => {
|
||||||
|
const id = InvoiceArkTypeId(req.params)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
const result = await this.manager.reissue({ invoiceId: id.id, createdBy: req.appUser.getUserData()?.id ?? null })
|
||||||
|
return this.sendTransition(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf = async (req: Request, res: Response) => {
|
||||||
|
const data = InvoiceArkTypePdf({ ...req.params, ...req.query })
|
||||||
|
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
|
||||||
|
const invoice = await this.manager.fetchById(data.id)
|
||||||
|
if (!invoice) return res.status(404).end()
|
||||||
|
const buffer = await this.manager.renderPdf({ invoiceId: data.id, lang: data.lang ?? 'en' })
|
||||||
|
if (!buffer) return res.status(404).end()
|
||||||
|
res.setHeader('Content-Type', 'application/pdf')
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(invoice.number)}.pdf"`)
|
||||||
|
return res.send(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendTransition(res: Response, result: InvoiceTransitionResult) {
|
||||||
|
if ('error' in result) return res.status(TRANSITION_ERROR_STATUS[result.error]).json(result)
|
||||||
|
return res.tvJson(result.invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete = async (req: Request, res: Response) => {
|
||||||
|
const id = InvoiceArkTypeId(req.params)
|
||||||
|
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
|
||||||
|
const result = await this.manager.delete(id.id)
|
||||||
|
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
|
||||||
|
return res.tvJson(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import type {
|
||||||
|
CounterpartiesSchemaTypeForSelect,
|
||||||
|
InvoiceCounterpartySnapshot,
|
||||||
|
InvoiceSellerSnapshot,
|
||||||
|
InvoiceStatus,
|
||||||
|
SellersSchemaTypeForSelect,
|
||||||
|
} from 'taskview-db-schemas'
|
||||||
|
import { BillingRepository } from '../billing/BillingRepository'
|
||||||
|
import { computeInvoiceTotals } from '../../utils/invoiceTotals'
|
||||||
|
import { InvoicePdfRenderer } from './InvoicePdfRenderer'
|
||||||
|
import { InvoicesRepository } from './InvoicesRepository'
|
||||||
|
import {
|
||||||
|
INVOICE_TEMPLATE_VERSION,
|
||||||
|
INVOICE_TRANSITIONS,
|
||||||
|
type InvoiceArgList,
|
||||||
|
type InvoiceArgUpdate,
|
||||||
|
type InvoiceCreateArgs,
|
||||||
|
type InvoiceDeleteResult,
|
||||||
|
type InvoiceForClient,
|
||||||
|
type InvoiceMissingRequisite,
|
||||||
|
type InvoiceReissueArgs,
|
||||||
|
type InvoiceRenderPdfArgs,
|
||||||
|
type InvoiceSetStatusArgs,
|
||||||
|
type InvoiceSnapshots,
|
||||||
|
type InvoiceStatusPatch,
|
||||||
|
type InvoiceTransitionResult,
|
||||||
|
type InvoiceUpdateArgs,
|
||||||
|
type InvoiceWithLines,
|
||||||
|
type InvoiceWriteError,
|
||||||
|
type InvoiceWriteResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export class InvoicesManager {
|
||||||
|
public readonly repository: InvoicesRepository
|
||||||
|
private readonly billing: BillingRepository
|
||||||
|
private readonly pdf: InvoicePdfRenderer
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = new InvoicesRepository()
|
||||||
|
this.billing = new BillingRepository()
|
||||||
|
this.pdf = new InvoicePdfRenderer()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchList(args: InvoiceArgList): Promise<InvoiceForClient[]> {
|
||||||
|
return (await this.repository.fetchList(args)).map((invoice) => this.toClient(invoice))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchById(invoiceId: number): Promise<InvoiceForClient | null> {
|
||||||
|
const invoice = await this.repository.fetchById(invoiceId)
|
||||||
|
return invoice ? this.toClient(invoice) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async create({ data, createdBy }: InvoiceCreateArgs): Promise<InvoiceWriteResult> {
|
||||||
|
const snapshots = await this.resolveSnapshots(data.organizationId, data)
|
||||||
|
if (typeof snapshots === 'string') return { error: snapshots }
|
||||||
|
const created = await this.repository.create({ data, createdBy, ...snapshots })
|
||||||
|
return this.toWriteResult(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async update({ invoiceId, data }: InvoiceUpdateArgs): Promise<InvoiceWriteResult> {
|
||||||
|
const existing = await this.repository.fetchById(invoiceId)
|
||||||
|
if (!existing) return { error: 'not_found' }
|
||||||
|
if (existing.status !== 'draft') return { error: 'not_draft' }
|
||||||
|
const snapshots = await this.resolveSnapshots(existing.organizationId, data)
|
||||||
|
if (typeof snapshots === 'string') return { error: snapshots }
|
||||||
|
const updated = await this.repository.update({ invoiceId, data, ...snapshots })
|
||||||
|
return this.toWriteResult(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async transition({ invoiceId, status }: InvoiceSetStatusArgs): Promise<InvoiceTransitionResult> {
|
||||||
|
const existing = await this.repository.fetchById(invoiceId)
|
||||||
|
if (!existing) return { error: 'not_found' }
|
||||||
|
if (!INVOICE_TRANSITIONS[existing.status].includes(status)) {
|
||||||
|
return { error: 'invalid_transition', from: existing.status, to: status }
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: InvoiceStatusPatch = { status }
|
||||||
|
if (status === 'issued' && existing.status === 'draft') {
|
||||||
|
const missing = this.missingRequisites(existing)
|
||||||
|
if (missing.length > 0) return { error: 'missing_requisites', missing }
|
||||||
|
const totals = computeInvoiceTotals(existing)
|
||||||
|
patch.issuedAt = new Date()
|
||||||
|
patch.templateVersion = INVOICE_TEMPLATE_VERSION
|
||||||
|
patch.subtotal = String(totals.subtotal)
|
||||||
|
patch.discountAmount = String(totals.discount)
|
||||||
|
patch.taxAmount = String(totals.tax)
|
||||||
|
patch.total = String(totals.total)
|
||||||
|
}
|
||||||
|
if (status === 'issued' && existing.status === 'paid') patch.paidAt = null
|
||||||
|
if (status === 'paid') patch.paidAt = new Date()
|
||||||
|
if (status === 'void') patch.voidedAt = new Date()
|
||||||
|
|
||||||
|
const updated = await this.repository.applyStatus({ invoiceId, patch })
|
||||||
|
return updated ? { invoice: this.toClient(updated) } : { error: 'not_found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async reissue({ invoiceId, createdBy }: InvoiceReissueArgs): Promise<InvoiceTransitionResult> {
|
||||||
|
const original = await this.repository.fetchById(invoiceId)
|
||||||
|
if (!original) return { error: 'not_found' }
|
||||||
|
if (original.status === 'issued') {
|
||||||
|
const voided = await this.transition({ invoiceId, status: 'void' })
|
||||||
|
if ('error' in voided) return voided
|
||||||
|
} else if (original.status !== 'void') {
|
||||||
|
return { error: 'invalid_transition', from: original.status, to: 'void' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = await this.repository.nextNumber({ organizationId: original.organizationId, base: original.number })
|
||||||
|
const created = await this.repository.create({
|
||||||
|
createdBy,
|
||||||
|
sellerSnapshot: original.sellerSnapshot,
|
||||||
|
counterpartySnapshot: original.counterpartySnapshot,
|
||||||
|
goalName: original.goalName,
|
||||||
|
data: {
|
||||||
|
organizationId: original.organizationId,
|
||||||
|
goalId: original.goalId,
|
||||||
|
sellerId: original.sellerId,
|
||||||
|
counterpartyId: original.counterpartyId,
|
||||||
|
number,
|
||||||
|
reference: original.reference,
|
||||||
|
currencyCode: original.currencyCode,
|
||||||
|
issueDate: new Date().toISOString().slice(0, 10),
|
||||||
|
paymentTerms: original.paymentTerms,
|
||||||
|
dueDate: original.dueDate,
|
||||||
|
periodFrom: original.periodFrom,
|
||||||
|
periodTo: original.periodTo,
|
||||||
|
discountType: original.discountType,
|
||||||
|
discountValue: Number(original.discountValue),
|
||||||
|
taxRate: Number(original.taxRate),
|
||||||
|
taxExempt: original.taxExempt,
|
||||||
|
taxNote: original.taxNote,
|
||||||
|
notes: original.notes,
|
||||||
|
terms: original.terms,
|
||||||
|
lines: original.lines.map((line) => ({
|
||||||
|
taskId: line.taskId,
|
||||||
|
description: line.description,
|
||||||
|
unit: line.unit,
|
||||||
|
quantity: Number(line.quantity),
|
||||||
|
unitPrice: Number(line.unitPrice),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!created || created === 'duplicate_number') return { error: 'not_found' }
|
||||||
|
await this.repository.setReplaces({ invoiceId: created.id, replacesInvoiceId: original.id })
|
||||||
|
return { invoice: this.toClient({ ...created, replacesInvoiceId: original.id }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(invoiceId: number): Promise<InvoiceDeleteResult> {
|
||||||
|
const existing = await this.repository.fetchById(invoiceId)
|
||||||
|
if (!existing) return 'not_found'
|
||||||
|
if (existing.status !== 'draft') return 'not_draft'
|
||||||
|
return (await this.repository.delete(invoiceId)) ? 'deleted' : 'not_found'
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderPdf({ invoiceId, lang }: InvoiceRenderPdfArgs): Promise<Buffer | null> {
|
||||||
|
const invoice = await this.fetchById(invoiceId)
|
||||||
|
if (!invoice) return null
|
||||||
|
return this.pdf.render(invoice, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
private missingRequisites(invoice: InvoiceWithLines): InvoiceMissingRequisite[] {
|
||||||
|
const seller = invoice.sellerSnapshot
|
||||||
|
const counterparty = invoice.counterpartySnapshot
|
||||||
|
const missing: InvoiceMissingRequisite[] = []
|
||||||
|
if (!seller.name.trim() && !seller.legalName.trim()) missing.push('seller.name')
|
||||||
|
if (!seller.address.trim()) missing.push('seller.address')
|
||||||
|
if (!seller.bank.accountNumber.trim() && !seller.bank.iban.trim()) missing.push('seller.bank')
|
||||||
|
if (!counterparty.name.trim() && !counterparty.legalName.trim()) missing.push('counterparty.name')
|
||||||
|
if (!counterparty.address.trim()) missing.push('counterparty.address')
|
||||||
|
if (invoice.lines.length === 0) missing.push('lines')
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSnapshots(organizationId: number, data: InvoiceArgUpdate): Promise<InvoiceSnapshots | InvoiceWriteError> {
|
||||||
|
const seller = await this.billing.fetchSellerById(data.sellerId)
|
||||||
|
if (!seller || seller.organizationId !== organizationId) return 'seller_not_found'
|
||||||
|
const counterparty = await this.billing.fetchCounterpartyById(data.counterpartyId)
|
||||||
|
if (!counterparty || counterparty.organizationId !== organizationId) return 'counterparty_not_found'
|
||||||
|
let goalName = ''
|
||||||
|
if (data.goalId !== null) {
|
||||||
|
const goal = await this.repository.fetchGoal(data.goalId)
|
||||||
|
if (!goal || goal.organizationId !== organizationId) return 'goal_not_found'
|
||||||
|
goalName = goal.name ?? ''
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sellerSnapshot: this.snapshotSeller(seller),
|
||||||
|
counterpartySnapshot: this.snapshotCounterparty(counterparty),
|
||||||
|
goalName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotSeller(seller: SellersSchemaTypeForSelect): InvoiceSellerSnapshot {
|
||||||
|
return {
|
||||||
|
name: seller.name,
|
||||||
|
legalName: seller.legalName,
|
||||||
|
address: seller.address,
|
||||||
|
email: seller.email,
|
||||||
|
phone: seller.phone,
|
||||||
|
logoUrl: seller.logoUrl,
|
||||||
|
bank: seller.bank,
|
||||||
|
requisites: seller.requisites,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshotCounterparty(counterparty: CounterpartiesSchemaTypeForSelect): InvoiceCounterpartySnapshot {
|
||||||
|
return {
|
||||||
|
kind: counterparty.kind,
|
||||||
|
name: counterparty.name,
|
||||||
|
legalName: counterparty.legalName,
|
||||||
|
address: counterparty.address,
|
||||||
|
email: counterparty.email,
|
||||||
|
phone: counterparty.phone,
|
||||||
|
contactPerson: counterparty.contactPerson,
|
||||||
|
requisites: counterparty.requisites,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toWriteResult(result: InvoiceWithLines | 'duplicate_number' | null): InvoiceWriteResult {
|
||||||
|
if (result === 'duplicate_number') return { error: 'duplicate_number' }
|
||||||
|
if (!result) return { error: 'not_found' }
|
||||||
|
return { invoice: this.toClient(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private toClient(invoice: InvoiceWithLines): InvoiceForClient {
|
||||||
|
const { sellerSnapshot, counterpartySnapshot, lines, subtotal, discountAmount, taxAmount, total, ...rest } = invoice
|
||||||
|
const totalsFrozen = total !== null
|
||||||
|
const totals = totalsFrozen
|
||||||
|
? {
|
||||||
|
subtotal: Number(subtotal),
|
||||||
|
discount: Number(discountAmount),
|
||||||
|
taxable: Number(subtotal) - Number(discountAmount),
|
||||||
|
tax: Number(taxAmount),
|
||||||
|
total: Number(total),
|
||||||
|
}
|
||||||
|
: computeInvoiceTotals(invoice)
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
discountValue: Number(invoice.discountValue),
|
||||||
|
taxRate: Number(invoice.taxRate),
|
||||||
|
seller: sellerSnapshot,
|
||||||
|
counterparty: counterpartySnapshot,
|
||||||
|
totals,
|
||||||
|
totalsFrozen,
|
||||||
|
lines: lines.map((line) => ({
|
||||||
|
id: line.id,
|
||||||
|
taskId: line.taskId,
|
||||||
|
description: line.description,
|
||||||
|
unit: line.unit,
|
||||||
|
quantity: Number(line.quantity),
|
||||||
|
unitPrice: Number(line.unitPrice),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { InvoiceStatus }
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
CounterpartiesSchema,
|
||||||
|
GoalsSchema,
|
||||||
|
InvoiceLinesSchema,
|
||||||
|
InvoicesSchema,
|
||||||
|
type InvoiceLinesSchemaTypeForSelect,
|
||||||
|
type InvoicesSchemaTypeForSelect,
|
||||||
|
} from 'taskview-db-schemas'
|
||||||
|
import { Database } from '../../modules/db'
|
||||||
|
import { callWithCatch } from '../../utils/helpers'
|
||||||
|
import type {
|
||||||
|
InvoiceArgList,
|
||||||
|
InvoiceArgUpdate,
|
||||||
|
InvoiceCreateRepoArgs,
|
||||||
|
InvoiceLineArg,
|
||||||
|
InvoiceNextNumberArgs,
|
||||||
|
InvoiceSetReplacesArgs,
|
||||||
|
InvoiceStatusRepoArgs,
|
||||||
|
InvoiceUpdateRepoArgs,
|
||||||
|
InvoiceWithLines,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const PG_UNIQUE_VIOLATION = '23505'
|
||||||
|
|
||||||
|
export class InvoicesRepository {
|
||||||
|
private readonly db: Database
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.db = Database.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchList({ organizationId, includeArchived }: InvoiceArgList): Promise<InvoiceWithLines[]> {
|
||||||
|
const conditions = [eq(InvoicesSchema.organizationId, organizationId)]
|
||||||
|
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
|
||||||
|
const rows = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select({ invoice: InvoicesSchema })
|
||||||
|
.from(InvoicesSchema)
|
||||||
|
.innerJoin(CounterpartiesSchema, eq(CounterpartiesSchema.id, InvoicesSchema.counterpartyId))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(InvoicesSchema.issueDate), desc(InvoicesSchema.id)),
|
||||||
|
)
|
||||||
|
const invoices = (rows ?? []).map((row) => row.invoice)
|
||||||
|
return this.attachLines(invoices)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchById(invoiceId: number): Promise<InvoiceWithLines | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.select().from(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)),
|
||||||
|
)
|
||||||
|
const invoice = result?.[0]
|
||||||
|
if (!invoice) return null
|
||||||
|
return (await this.attachLines([invoice]))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchOrganizationId(invoiceId: number): Promise<number | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select({ organizationId: InvoicesSchema.organizationId })
|
||||||
|
.from(InvoicesSchema)
|
||||||
|
.where(eq(InvoicesSchema.id, invoiceId)),
|
||||||
|
)
|
||||||
|
return result?.[0]?.organizationId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchGoal(goalId: number): Promise<{ organizationId: number | null; name: string | null } | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select({ organizationId: GoalsSchema.organizationId, name: GoalsSchema.name })
|
||||||
|
.from(GoalsSchema)
|
||||||
|
.where(eq(GoalsSchema.id, goalId)),
|
||||||
|
)
|
||||||
|
return result?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private invoiceColumns(data: InvoiceArgUpdate) {
|
||||||
|
return {
|
||||||
|
goalId: data.goalId,
|
||||||
|
sellerId: data.sellerId,
|
||||||
|
counterpartyId: data.counterpartyId,
|
||||||
|
number: data.number,
|
||||||
|
reference: data.reference,
|
||||||
|
currencyCode: data.currencyCode,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
paymentTerms: data.paymentTerms,
|
||||||
|
dueDate: data.dueDate,
|
||||||
|
periodFrom: data.periodFrom,
|
||||||
|
periodTo: data.periodTo,
|
||||||
|
discountType: data.discountType,
|
||||||
|
discountValue: String(data.discountValue),
|
||||||
|
taxRate: String(data.taxRate),
|
||||||
|
taxExempt: data.taxExempt,
|
||||||
|
taxNote: data.taxNote,
|
||||||
|
notes: data.notes,
|
||||||
|
terms: data.terms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create({ data, createdBy, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceCreateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
|
||||||
|
try {
|
||||||
|
return await this.db.dbDrizzle.transaction(async (tx) => {
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(InvoicesSchema)
|
||||||
|
.values({
|
||||||
|
organizationId: data.organizationId,
|
||||||
|
...this.invoiceColumns(data),
|
||||||
|
goalName,
|
||||||
|
sellerSnapshot,
|
||||||
|
counterpartySnapshot,
|
||||||
|
createdBy,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
const created = inserted[0]
|
||||||
|
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(created.id, data.lines)).returning()
|
||||||
|
return { ...created, lines: insertedLines }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUniqueViolation(error)) return 'duplicate_number'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update({ invoiceId, data, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceUpdateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
|
||||||
|
try {
|
||||||
|
return await this.db.dbDrizzle.transaction(async (tx) => {
|
||||||
|
const updated = await tx
|
||||||
|
.update(InvoicesSchema)
|
||||||
|
.set({
|
||||||
|
...this.invoiceColumns(data),
|
||||||
|
goalName,
|
||||||
|
sellerSnapshot,
|
||||||
|
counterpartySnapshot,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(InvoicesSchema.id, invoiceId))
|
||||||
|
.returning()
|
||||||
|
const current = updated[0]
|
||||||
|
if (!current) return null
|
||||||
|
await tx.delete(InvoiceLinesSchema).where(eq(InvoiceLinesSchema.invoiceId, invoiceId))
|
||||||
|
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(invoiceId, data.lines)).returning()
|
||||||
|
return { ...current, lines: insertedLines }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUniqueViolation(error)) return 'duplicate_number'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyStatus({ invoiceId, patch }: InvoiceStatusRepoArgs): Promise<InvoiceWithLines | null> {
|
||||||
|
const result = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.update(InvoicesSchema)
|
||||||
|
.set({ ...patch, updatedAt: new Date() })
|
||||||
|
.where(eq(InvoicesSchema.id, invoiceId))
|
||||||
|
.returning(),
|
||||||
|
)
|
||||||
|
const invoice = result?.[0]
|
||||||
|
if (!invoice) return null
|
||||||
|
return (await this.attachLines([invoice]))[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async setReplaces({ invoiceId, replacesInvoiceId }: InvoiceSetReplacesArgs): Promise<void> {
|
||||||
|
await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle.update(InvoicesSchema).set({ replacesInvoiceId }).where(eq(InvoicesSchema.id, invoiceId)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextNumber({ organizationId, base }: InvoiceNextNumberArgs): Promise<string> {
|
||||||
|
const match = base.match(/^(.*?)(\d+)$/)
|
||||||
|
if (!match) return `${base}-1`
|
||||||
|
const [, prefix, digits] = match
|
||||||
|
const rows = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select({ number: InvoicesSchema.number })
|
||||||
|
.from(InvoicesSchema)
|
||||||
|
.where(eq(InvoicesSchema.organizationId, organizationId)),
|
||||||
|
)
|
||||||
|
let max = Number(digits)
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
if (!row.number.startsWith(prefix)) continue
|
||||||
|
const tail = row.number.slice(prefix.length)
|
||||||
|
if (/^\d+$/.test(tail)) max = Math.max(max, Number(tail))
|
||||||
|
}
|
||||||
|
return `${prefix}${String(max + 1).padStart(digits.length, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(invoiceId: number): Promise<boolean> {
|
||||||
|
const result = await callWithCatch(() => this.db.dbDrizzle.delete(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)))
|
||||||
|
return !!result?.rowCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLineRows(invoiceId: number, lines: InvoiceLineArg[]) {
|
||||||
|
return lines.map((line, index) => ({
|
||||||
|
invoiceId,
|
||||||
|
position: index,
|
||||||
|
taskId: line.taskId,
|
||||||
|
description: line.description,
|
||||||
|
unit: line.unit,
|
||||||
|
quantity: String(line.quantity),
|
||||||
|
unitPrice: String(line.unitPrice),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attachLines(invoices: InvoicesSchemaTypeForSelect[]): Promise<InvoiceWithLines[]> {
|
||||||
|
if (invoices.length === 0) return []
|
||||||
|
const lines = await callWithCatch(() =>
|
||||||
|
this.db.dbDrizzle
|
||||||
|
.select()
|
||||||
|
.from(InvoiceLinesSchema)
|
||||||
|
.where(inArray(InvoiceLinesSchema.invoiceId, invoices.map((invoice) => invoice.id)))
|
||||||
|
.orderBy(asc(InvoiceLinesSchema.invoiceId), asc(InvoiceLinesSchema.position)),
|
||||||
|
)
|
||||||
|
const byInvoice = new Map<number, InvoiceLinesSchemaTypeForSelect[]>()
|
||||||
|
for (const line of lines ?? []) {
|
||||||
|
const list = byInvoice.get(line.invoiceId) ?? []
|
||||||
|
list.push(line)
|
||||||
|
byInvoice.set(line.invoiceId, list)
|
||||||
|
}
|
||||||
|
return invoices.map((invoice) => ({ ...invoice, lines: byInvoice.get(invoice.id) ?? [] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUniqueViolation(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null) return false
|
||||||
|
const { code, cause } = error as { code?: string; cause?: { code?: string } }
|
||||||
|
return code === PG_UNIQUE_VIOLATION || cause?.code === PG_UNIQUE_VIOLATION
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import type { Routable } from '../../types/routable.type'
|
||||||
|
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||||
|
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
|
||||||
|
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
|
||||||
|
import { GoalPermissions } from '../../types/auth.types'
|
||||||
|
import { InvoicesController } from './InvoicesController'
|
||||||
|
import { isOrgAdminForInvoice } from './middlewares/is-org-admin-for-invoice'
|
||||||
|
|
||||||
|
export default class InvoicesRoutes implements Routable {
|
||||||
|
private readonly router: ReturnType<typeof Router>
|
||||||
|
private readonly controller: InvoicesController
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router = Router()
|
||||||
|
this.controller = new InvoicesController()
|
||||||
|
this.initRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
getRouter() {
|
||||||
|
return this.router
|
||||||
|
}
|
||||||
|
|
||||||
|
private initRoutes() {
|
||||||
|
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
|
||||||
|
|
||||||
|
this.router.get('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetch)
|
||||||
|
this.router.post('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.create)
|
||||||
|
this.router.get('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.getById)
|
||||||
|
this.router.patch('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.update)
|
||||||
|
this.router.patch('/:id/status', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.setStatus)
|
||||||
|
this.router.post('/:id/reissue', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.reissue)
|
||||||
|
this.router.get('/:id/pdf', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.pdf)
|
||||||
|
this.router.delete('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type http from 'http'
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import App from '../../../App'
|
||||||
|
|
||||||
|
const port = 1811
|
||||||
|
const api = axios.create({ baseURL: `http://localhost:${port}`, validateStatus: () => true })
|
||||||
|
|
||||||
|
const LOGIN = 'test@mail.dest'
|
||||||
|
const PASSWORD = 'user1!#Q'
|
||||||
|
|
||||||
|
let server: http.Server
|
||||||
|
let jwt = ''
|
||||||
|
let organizationId = 0
|
||||||
|
let goalId = 0
|
||||||
|
let sellerId = 0
|
||||||
|
let counterpartyId = 0
|
||||||
|
let invoiceId = 0
|
||||||
|
|
||||||
|
const auth = () => ({ headers: { Authorization: `Bearer ${jwt}` } })
|
||||||
|
|
||||||
|
const sellerPayload = () => ({
|
||||||
|
organizationId,
|
||||||
|
name: 'IT Seller',
|
||||||
|
legalName: 'IT Seller LLC',
|
||||||
|
address: 'Somewhere 1',
|
||||||
|
email: 'billing@seller.test',
|
||||||
|
phone: '',
|
||||||
|
logoUrl: '',
|
||||||
|
currencyCode: 'EUR',
|
||||||
|
bank: { bankName: 'Bank', accountNumber: '123', iban: 'DE00', swift: 'XXX', correspondentAccount: '' },
|
||||||
|
requisites: [{ key: 'vat', label: 'VAT ID', value: 'DE1' }],
|
||||||
|
defaultTerms: 'Net 14',
|
||||||
|
taxNote: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const counterpartyPayload = () => ({
|
||||||
|
organizationId,
|
||||||
|
kind: 'organization',
|
||||||
|
name: 'IT Client',
|
||||||
|
legalName: 'IT Client GmbH',
|
||||||
|
address: '',
|
||||||
|
email: 'ap@client.test',
|
||||||
|
phone: '',
|
||||||
|
contactPerson: 'Anna',
|
||||||
|
requisites: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const invoicePayload = (number: string) => ({
|
||||||
|
organizationId,
|
||||||
|
goalId,
|
||||||
|
sellerId,
|
||||||
|
counterpartyId,
|
||||||
|
number,
|
||||||
|
reference: 'PO-1',
|
||||||
|
currencyCode: 'EUR',
|
||||||
|
issueDate: '2026-09-01',
|
||||||
|
paymentTerms: 'net14',
|
||||||
|
dueDate: '2026-09-15',
|
||||||
|
periodFrom: null,
|
||||||
|
periodTo: null,
|
||||||
|
discountType: 'percent',
|
||||||
|
discountValue: 10,
|
||||||
|
taxRate: 19,
|
||||||
|
taxExempt: false,
|
||||||
|
taxNote: '',
|
||||||
|
notes: '',
|
||||||
|
terms: 'Net 14',
|
||||||
|
lines: [
|
||||||
|
{ taskId: null, description: 'Design', unit: 'service', quantity: 1, unitPrice: 1000 },
|
||||||
|
{ taskId: null, description: 'Dev', unit: 'hours', quantity: 10.5, unitPrice: 80 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('billing and invoices', () => {
|
||||||
|
vi.mock('emailjs', () => ({
|
||||||
|
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new App(port).listen()
|
||||||
|
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD })
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
jwt = login.data.access
|
||||||
|
|
||||||
|
const org = await api.post('/module/organizations', { name: `billing-it-${Date.now()}` }, auth())
|
||||||
|
expect(org.status).toBe(200)
|
||||||
|
organizationId = org.data.response.id
|
||||||
|
|
||||||
|
const goal = await api.post('/module/goals', { name: 'billing-it-goal', organizationId }, auth())
|
||||||
|
expect(goal.status).toBe(200)
|
||||||
|
goalId = goal.data.response.id ?? goal.data.response.goal?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (organizationId) await api.delete(`/module/organizations/${organizationId}`, auth())
|
||||||
|
server?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists the seeded currencies', async () => {
|
||||||
|
const response = await api.get('/module/billing/currencies', auth())
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.data.response.length).toBeGreaterThanOrEqual(20)
|
||||||
|
expect(response.data.response.find((c: { code: string }) => c.code === 'JPY').decimalDigits).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a seller and a counterparty for the organization', async () => {
|
||||||
|
const seller = await api.post('/module/billing/sellers', sellerPayload(), auth())
|
||||||
|
expect(seller.status).toBe(200)
|
||||||
|
sellerId = seller.data.response.id
|
||||||
|
expect(seller.data.response.bank.iban).toBe('DE00')
|
||||||
|
|
||||||
|
const counterparty = await api.post('/module/billing/counterparties', counterpartyPayload(), auth())
|
||||||
|
expect(counterparty.status).toBe(200)
|
||||||
|
counterpartyId = counterparty.data.response.id
|
||||||
|
|
||||||
|
const list = await api.get('/module/billing/counterparties', { params: { organizationId }, ...auth() })
|
||||||
|
expect(list.data.response.map((c: { id: number }) => c.id)).toContain(counterpartyId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid payload', async () => {
|
||||||
|
const response = await api.post('/module/billing/sellers', { ...sellerPayload(), currencyCode: 'euro' }, auth())
|
||||||
|
expect(response.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates an invoice with snapshots, lines and the project name', async () => {
|
||||||
|
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const invoice = response.data.response
|
||||||
|
invoiceId = invoice.id
|
||||||
|
expect(invoice.status).toBe('draft')
|
||||||
|
expect(invoice.goalName).toBe('billing-it-goal')
|
||||||
|
expect(invoice.seller.legalName).toBe('IT Seller LLC')
|
||||||
|
expect(invoice.counterparty.contactPerson).toBe('Anna')
|
||||||
|
expect(invoice.lines).toHaveLength(2)
|
||||||
|
expect(invoice.lines[1].quantity).toBe(10.5)
|
||||||
|
expect(invoice.discountValue).toBe(10)
|
||||||
|
expect(typeof invoice.taxRate).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a duplicate number inside the organization', async () => {
|
||||||
|
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
|
||||||
|
expect(response.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a seller from another organization', async () => {
|
||||||
|
const response = await api.post('/module/invoices', { ...invoicePayload('INV-IT-2'), sellerId: 999999 }, auth())
|
||||||
|
expect(response.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the seller snapshot when the seller is edited', async () => {
|
||||||
|
const update = await api.patch(`/module/billing/sellers/${sellerId}`, { ...sellerPayload(), organizationId: undefined, legalName: 'Renamed LLC' }, auth())
|
||||||
|
expect(update.status).toBe(200)
|
||||||
|
const invoice = await api.get(`/module/invoices/${invoiceId}`, auth())
|
||||||
|
expect(invoice.data.response.seller.legalName).toBe('IT Seller LLC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces lines on update', async () => {
|
||||||
|
const data = { ...invoicePayload('INV-IT-1'), organizationId: undefined, lines: [{ taskId: null, description: 'Only', unit: 'pcs', quantity: 2, unitPrice: 5 }] }
|
||||||
|
const response = await api.patch(`/module/invoices/${invoiceId}`, data, auth())
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.data.response.lines).toHaveLength(1)
|
||||||
|
expect(response.data.response.lines[0].description).toBe('Only')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to delete a seller that has invoices', async () => {
|
||||||
|
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
|
||||||
|
expect(response.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides invoices of an archived client unless asked for', async () => {
|
||||||
|
const archive = await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: true }, auth())
|
||||||
|
expect(archive.status).toBe(200)
|
||||||
|
expect(archive.data.response.archived).toBe(true)
|
||||||
|
|
||||||
|
const hidden = await api.get('/module/invoices', { params: { organizationId }, ...auth() })
|
||||||
|
expect(hidden.data.response.map((i: { id: number }) => i.id)).not.toContain(invoiceId)
|
||||||
|
|
||||||
|
const shown = await api.get('/module/invoices', { params: { organizationId, includeArchived: true }, ...auth() })
|
||||||
|
expect(shown.data.response.map((i: { id: number }) => i.id)).toContain(invoiceId)
|
||||||
|
|
||||||
|
await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: false }, auth())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to pay a draft and reports what is missing before issuing', async () => {
|
||||||
|
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
|
||||||
|
expect(paid.status).toBe(409)
|
||||||
|
expect(paid.data.error).toBe('invalid_transition')
|
||||||
|
|
||||||
|
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
|
||||||
|
expect(issued.status).toBe(422)
|
||||||
|
expect(issued.data.missing).toContain('counterparty.address')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('issues the invoice, freezes the totals and locks editing', async () => {
|
||||||
|
const fixed = await api.patch(`/module/billing/counterparties/${counterpartyId}`, { ...counterpartyPayload(), organizationId: undefined, address: 'Client street 1' }, auth())
|
||||||
|
expect(fixed.status).toBe(200)
|
||||||
|
const resnap = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
|
||||||
|
expect(resnap.status).toBe(200)
|
||||||
|
|
||||||
|
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
|
||||||
|
expect(issued.status).toBe(200)
|
||||||
|
expect(issued.data.response.status).toBe('issued')
|
||||||
|
expect(issued.data.response.issuedAt).toBeTruthy()
|
||||||
|
expect(issued.data.response.totalsFrozen).toBe(true)
|
||||||
|
expect(issued.data.response.totals.total).toBe(1970.64)
|
||||||
|
|
||||||
|
const edit = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
|
||||||
|
expect(edit.status).toBe(409)
|
||||||
|
const del = await api.delete(`/module/invoices/${invoiceId}`, auth())
|
||||||
|
expect(del.status).toBe(409)
|
||||||
|
const back = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'draft' }, auth())
|
||||||
|
expect(back.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks paid, unmarks and refuses to void a paid invoice', async () => {
|
||||||
|
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
|
||||||
|
expect(paid.data.response.paidAt).toBeTruthy()
|
||||||
|
const voided = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'void' }, auth())
|
||||||
|
expect(voided.status).toBe(409)
|
||||||
|
const unpaid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
|
||||||
|
expect(unpaid.data.response.paidAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the invoice as a PDF', async () => {
|
||||||
|
const response = await api.get(`/module/invoices/${invoiceId}/pdf`, { ...auth(), params: { lang: 'ru' }, responseType: 'arraybuffer' })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.headers['content-type']).toContain('application/pdf')
|
||||||
|
expect(Buffer.from(response.data).subarray(0, 4).toString()).toBe('%PDF')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reissues: voids the original and creates a draft copy with the next number', async () => {
|
||||||
|
const response = await api.post(`/module/invoices/${invoiceId}/reissue`, {}, auth())
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const copy = response.data.response
|
||||||
|
expect(copy.status).toBe('draft')
|
||||||
|
expect(copy.number).toBe('INV-IT-2')
|
||||||
|
expect(copy.replacesInvoiceId).toBe(invoiceId)
|
||||||
|
expect(copy.lines).toHaveLength(2)
|
||||||
|
|
||||||
|
const original = await api.get(`/module/invoices/${invoiceId}`, auth())
|
||||||
|
expect(original.data.response.status).toBe('void')
|
||||||
|
expect(original.data.response.voidedAt).toBeTruthy()
|
||||||
|
|
||||||
|
const revive = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
|
||||||
|
expect(revive.status).toBe(409)
|
||||||
|
const deleted = await api.delete(`/module/invoices/${copy.id}`, auth())
|
||||||
|
expect(deleted.status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a seller with a voided invoice undeletable', async () => {
|
||||||
|
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
|
||||||
|
expect(response.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an anonymous request', async () => {
|
||||||
|
const response = await api.get('/module/invoices', { params: { organizationId } })
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { isOrgAdminFor } from '../../billing/middlewares/is-org-admin-for'
|
||||||
|
import { InvoicesRepository } from '../InvoicesRepository'
|
||||||
|
|
||||||
|
const repository = new InvoicesRepository()
|
||||||
|
|
||||||
|
export const isOrgAdminForInvoice = isOrgAdminFor((id) => repository.fetchOrganizationId(id))
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { type } from 'arktype'
|
||||||
|
import type {
|
||||||
|
InvoiceCounterpartySnapshot,
|
||||||
|
InvoiceLinesSchemaTypeForSelect,
|
||||||
|
InvoiceSellerSnapshot,
|
||||||
|
InvoiceStatus,
|
||||||
|
InvoicesSchemaTypeForSelect,
|
||||||
|
} from 'taskview-db-schemas'
|
||||||
|
import type { InvoiceTotalsResult } from '../../utils/invoiceTotals'
|
||||||
|
|
||||||
|
const NumberFromString = type('string|number').pipe((v) => Number(v))
|
||||||
|
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
|
||||||
|
if (v === undefined) return undefined
|
||||||
|
if (typeof v === 'boolean') return v
|
||||||
|
return v === 'true' || v === '1'
|
||||||
|
})
|
||||||
|
const DateString = type(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
const DateStringOrNull = DateString.or('null')
|
||||||
|
|
||||||
|
export const InvoiceLineArkType = type({
|
||||||
|
taskId: 'number|null',
|
||||||
|
description: '1<=string<=1000',
|
||||||
|
unit: "'service' | 'hours' | 'pcs'",
|
||||||
|
quantity: 'number>=0',
|
||||||
|
unitPrice: 'number>=0',
|
||||||
|
})
|
||||||
|
export type InvoiceLineArg = typeof InvoiceLineArkType.infer
|
||||||
|
|
||||||
|
export const InvoiceArkTypeCreate = type({
|
||||||
|
organizationId: 'number',
|
||||||
|
goalId: 'number|null',
|
||||||
|
sellerId: 'number',
|
||||||
|
counterpartyId: 'number',
|
||||||
|
number: '1<=string<=50',
|
||||||
|
reference: 'string<=200',
|
||||||
|
currencyCode: /^[A-Z]{3}$/,
|
||||||
|
issueDate: DateString,
|
||||||
|
paymentTerms: "'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'",
|
||||||
|
dueDate: DateStringOrNull,
|
||||||
|
periodFrom: DateStringOrNull,
|
||||||
|
periodTo: DateStringOrNull,
|
||||||
|
discountType: "'percent' | 'amount'",
|
||||||
|
discountValue: 'number>=0',
|
||||||
|
taxRate: '0<=number<=100',
|
||||||
|
taxExempt: 'boolean',
|
||||||
|
taxNote: 'string<=500',
|
||||||
|
notes: 'string<=2000',
|
||||||
|
terms: 'string<=2000',
|
||||||
|
lines: InvoiceLineArkType.array().atLeastLength(1),
|
||||||
|
})
|
||||||
|
export type InvoiceArgCreate = typeof InvoiceArkTypeCreate.infer
|
||||||
|
|
||||||
|
export const InvoiceArkTypeUpdate = InvoiceArkTypeCreate.omit('organizationId')
|
||||||
|
export type InvoiceArgUpdate = typeof InvoiceArkTypeUpdate.infer
|
||||||
|
|
||||||
|
export const InvoiceArkTypeList = type({
|
||||||
|
organizationId: NumberFromString,
|
||||||
|
'includeArchived?': BooleanFromString,
|
||||||
|
})
|
||||||
|
export type InvoiceArgList = typeof InvoiceArkTypeList.infer
|
||||||
|
|
||||||
|
export const InvoiceArkTypeId = type({
|
||||||
|
id: NumberFromString,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const InvoiceArkTypeStatus = type({
|
||||||
|
status: "'draft' | 'issued' | 'paid' | 'void'",
|
||||||
|
})
|
||||||
|
export type InvoiceArgStatus = typeof InvoiceArkTypeStatus.infer
|
||||||
|
|
||||||
|
export const InvoiceArkTypePdf = type({
|
||||||
|
id: NumberFromString,
|
||||||
|
'lang?': "'en' | 'ru'",
|
||||||
|
})
|
||||||
|
export type InvoiceArgPdf = typeof InvoiceArkTypePdf.infer
|
||||||
|
|
||||||
|
export const INVOICE_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
|
||||||
|
draft: ['issued'],
|
||||||
|
issued: ['paid', 'void'],
|
||||||
|
paid: ['issued'],
|
||||||
|
void: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INVOICE_TEMPLATE_VERSION = 1
|
||||||
|
|
||||||
|
export type InvoiceMissingRequisite =
|
||||||
|
| 'seller.name'
|
||||||
|
| 'seller.address'
|
||||||
|
| 'seller.bank'
|
||||||
|
| 'counterparty.name'
|
||||||
|
| 'counterparty.address'
|
||||||
|
| 'lines'
|
||||||
|
|
||||||
|
export type InvoiceStatusPatch = {
|
||||||
|
status: InvoiceStatus
|
||||||
|
issuedAt?: Date | null
|
||||||
|
paidAt?: Date | null
|
||||||
|
voidedAt?: Date | null
|
||||||
|
templateVersion?: number
|
||||||
|
subtotal?: string | null
|
||||||
|
discountAmount?: string | null
|
||||||
|
taxAmount?: string | null
|
||||||
|
total?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceStatusRepoArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
patch: InvoiceStatusPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceSetReplacesArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
replacesInvoiceId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceReissueArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
createdBy: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceTransitionError =
|
||||||
|
| { error: 'not_found' }
|
||||||
|
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
|
||||||
|
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
|
||||||
|
|
||||||
|
export type InvoiceTransitionResult = { invoice: InvoiceForClient } | InvoiceTransitionError
|
||||||
|
|
||||||
|
export type InvoicePdfLang = 'en' | 'ru'
|
||||||
|
|
||||||
|
export type InvoiceRenderPdfArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
lang: InvoicePdfLang
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PdfmakeServer = {
|
||||||
|
virtualfs: { writeFileSync(filename: string, content: Buffer): void }
|
||||||
|
setFonts(fonts: Record<string, { normal: string; bold: string; italics: string; bolditalics: string }>): void
|
||||||
|
createPdf(docDefinition: unknown): { getBuffer(): Promise<Buffer> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceSnapshots = {
|
||||||
|
sellerSnapshot: InvoiceSellerSnapshot
|
||||||
|
counterpartySnapshot: InvoiceCounterpartySnapshot
|
||||||
|
goalName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceCreateRepoArgs = InvoiceSnapshots & {
|
||||||
|
data: InvoiceArgCreate
|
||||||
|
createdBy: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceUpdateRepoArgs = InvoiceSnapshots & {
|
||||||
|
invoiceId: number
|
||||||
|
data: InvoiceArgUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceCreateArgs = {
|
||||||
|
data: InvoiceArgCreate
|
||||||
|
createdBy: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceUpdateArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
data: InvoiceArgUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceSetStatusArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
status: InvoiceArgStatus['status']
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceWithLines = InvoicesSchemaTypeForSelect & {
|
||||||
|
lines: InvoiceLinesSchemaTypeForSelect[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceLineForClient = Omit<InvoiceLinesSchemaTypeForSelect, 'invoiceId' | 'position' | 'quantity' | 'unitPrice'> & {
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceForClient = Omit<
|
||||||
|
InvoicesSchemaTypeForSelect,
|
||||||
|
'discountValue' | 'taxRate' | 'sellerSnapshot' | 'counterpartySnapshot' | 'subtotal' | 'discountAmount' | 'taxAmount' | 'total'
|
||||||
|
> & {
|
||||||
|
discountValue: number
|
||||||
|
taxRate: number
|
||||||
|
seller: InvoiceSellerSnapshot
|
||||||
|
counterparty: InvoiceCounterpartySnapshot
|
||||||
|
lines: InvoiceLineForClient[]
|
||||||
|
totals: InvoiceTotalsResult
|
||||||
|
totalsFrozen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceWriteError = 'seller_not_found' | 'counterparty_not_found' | 'goal_not_found' | 'duplicate_number' | 'not_found' | 'not_draft'
|
||||||
|
|
||||||
|
export type InvoiceWriteResult = { invoice: InvoiceForClient } | { error: InvoiceWriteError }
|
||||||
|
|
||||||
|
export type InvoiceDeleteResult = 'deleted' | 'not_draft' | 'not_found'
|
||||||
|
|
||||||
|
export type InvoiceNextNumberArgs = {
|
||||||
|
organizationId: number
|
||||||
|
base: string
|
||||||
|
}
|
||||||
@@ -199,6 +199,7 @@ export const GoalPermissions = {
|
|||||||
ORG_CAN_MANAGE_MEMBERS: 'org_can_manage_members',
|
ORG_CAN_MANAGE_MEMBERS: 'org_can_manage_members',
|
||||||
SSO_CAN_MANAGE: 'sso_can_manage',
|
SSO_CAN_MANAGE: 'sso_can_manage',
|
||||||
WEBHOOKS_CAN_MANAGE: 'webhooks_can_manage',
|
WEBHOOKS_CAN_MANAGE: 'webhooks_can_manage',
|
||||||
|
BILLING_CAN_MANAGE: 'billing_can_manage',
|
||||||
|
|
||||||
SPRINT_CAN_VIEW: 'sprint_can_view',
|
SPRINT_CAN_VIEW: 'sprint_can_view',
|
||||||
SPRINT_CAN_MANAGE: 'sprint_can_manage',
|
SPRINT_CAN_MANAGE: 'sprint_can_manage',
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'pdfmake/build/vfs_fonts.js' {
|
||||||
|
const vfs: Record<string, string>
|
||||||
|
export default vfs
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type InvoiceTotalsInput = {
|
||||||
|
lines: { quantity: number | string; unitPrice: number | string }[]
|
||||||
|
discountType: 'percent' | 'amount'
|
||||||
|
discountValue: number | string
|
||||||
|
taxRate: number | string
|
||||||
|
taxExempt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceTotalsResult = {
|
||||||
|
subtotal: number
|
||||||
|
discount: number
|
||||||
|
taxable: number
|
||||||
|
tax: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invoiceLineAmount(quantity: number | string, unitPrice: number | string): number {
|
||||||
|
return round2(Number(quantity) * Number(unitPrice))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeInvoiceTotals(input: InvoiceTotalsInput): InvoiceTotalsResult {
|
||||||
|
const subtotal = round2(input.lines.reduce((sum, line) => sum + invoiceLineAmount(line.quantity, line.unitPrice), 0))
|
||||||
|
const discountValue = Number(input.discountValue)
|
||||||
|
const rawDiscount = input.discountType === 'percent' ? subtotal * (discountValue / 100) : discountValue
|
||||||
|
const discount = round2(Math.min(Math.max(rawDiscount, 0), subtotal))
|
||||||
|
const taxable = round2(subtotal - discount)
|
||||||
|
const tax = input.taxExempt ? 0 : round2(taxable * (Number(input.taxRate) / 100))
|
||||||
|
return { subtotal, discount, taxable, tax, total: round2(taxable + tax) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import TvApiBase from './base'
|
||||||
|
import type { AppResponse } from './base.types'
|
||||||
|
import type {
|
||||||
|
BillingArgArchive,
|
||||||
|
BillingArgList,
|
||||||
|
CounterpartyArgCreate,
|
||||||
|
CounterpartyArgUpdate,
|
||||||
|
CounterpartyItem,
|
||||||
|
CurrencyItem,
|
||||||
|
SellerArgCreate,
|
||||||
|
SellerArgUpdate,
|
||||||
|
SellerItem,
|
||||||
|
} from './billing.types'
|
||||||
|
|
||||||
|
export default class TvBillingApi extends TvApiBase {
|
||||||
|
protected moduleUrl = '/module/billing'
|
||||||
|
|
||||||
|
public async fetchCurrencies() {
|
||||||
|
return this.request(this.$axios.get<AppResponse<CurrencyItem[]>>(`${this.moduleUrl}/currencies`))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchSellers(params: BillingArgList) {
|
||||||
|
return this.request(this.$axios.get<AppResponse<SellerItem[]>>(`${this.moduleUrl}/sellers`, { params }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSeller(data: SellerArgCreate) {
|
||||||
|
return this.request(this.$axios.post<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers`, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateSeller({ id, data }: SellerArgUpdate) {
|
||||||
|
return this.request(this.$axios.patch<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers/${id}`, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async archiveSeller({ id, archived }: BillingArgArchive) {
|
||||||
|
return this.request(this.$axios.patch<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers/${id}/archive`, { archived }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteSeller(id: number) {
|
||||||
|
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/sellers/${id}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchCounterparties(params: BillingArgList) {
|
||||||
|
return this.request(this.$axios.get<AppResponse<CounterpartyItem[]>>(`${this.moduleUrl}/counterparties`, { params }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createCounterparty(data: CounterpartyArgCreate) {
|
||||||
|
return this.request(this.$axios.post<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties`, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateCounterparty({ id, data }: CounterpartyArgUpdate) {
|
||||||
|
return this.request(this.$axios.patch<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties/${id}`, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async archiveCounterparty({ id, archived }: BillingArgArchive) {
|
||||||
|
return this.request(
|
||||||
|
this.$axios.patch<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties/${id}/archive`, { archived }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteCounterparty(id: number) {
|
||||||
|
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/counterparties/${id}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export type CurrencyItem = {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
numericCode: number
|
||||||
|
name: string
|
||||||
|
symbol: string
|
||||||
|
decimalDigits: number
|
||||||
|
sortOrder: number
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingRequisite = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingBankDetails = {
|
||||||
|
bankName: string
|
||||||
|
accountNumber: string
|
||||||
|
iban: string
|
||||||
|
swift: string
|
||||||
|
correspondentAccount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartyKind = 'organization' | 'person'
|
||||||
|
|
||||||
|
export type SellerItem = {
|
||||||
|
id: number
|
||||||
|
organizationId: number
|
||||||
|
name: string
|
||||||
|
legalName: string
|
||||||
|
address: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
logoUrl: string
|
||||||
|
currencyCode: string
|
||||||
|
bank: BillingBankDetails
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
defaultTerms: string
|
||||||
|
taxNote: string
|
||||||
|
archived: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SellerArgCreate = Omit<SellerItem, 'id' | 'archived' | 'createdAt' | 'updatedAt'>
|
||||||
|
|
||||||
|
export type SellerArgUpdate = {
|
||||||
|
id: number
|
||||||
|
data: Omit<SellerArgCreate, 'organizationId'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartyItem = {
|
||||||
|
id: number
|
||||||
|
organizationId: number
|
||||||
|
kind: CounterpartyKind
|
||||||
|
name: string
|
||||||
|
legalName: string
|
||||||
|
address: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
contactPerson: string
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
archived: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartyArgCreate = Omit<CounterpartyItem, 'id' | 'archived' | 'createdAt' | 'updatedAt'>
|
||||||
|
|
||||||
|
export type CounterpartyArgUpdate = {
|
||||||
|
id: number
|
||||||
|
data: Omit<CounterpartyArgCreate, 'organizationId'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingArgList = {
|
||||||
|
organizationId: number
|
||||||
|
includeArchived?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingArgArchive = {
|
||||||
|
id: number
|
||||||
|
archived: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import TvApiBase from './base'
|
||||||
|
import type { AppResponse } from './base.types'
|
||||||
|
import type { InvoiceArgCreate, InvoiceArgList, InvoiceArgPdf, InvoiceArgStatus, InvoiceArgUpdate, InvoiceItem } from './invoices.types'
|
||||||
|
|
||||||
|
export default class TvInvoicesApi extends TvApiBase {
|
||||||
|
protected moduleUrl = '/module/invoices'
|
||||||
|
|
||||||
|
public async fetch(params: InvoiceArgList) {
|
||||||
|
return this.request(this.$axios.get<AppResponse<InvoiceItem[]>>(`${this.moduleUrl}`, { params }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchById(id: number) {
|
||||||
|
return this.request(this.$axios.get<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: InvoiceArgCreate) {
|
||||||
|
return this.request(this.$axios.post<AppResponse<InvoiceItem>>(`${this.moduleUrl}`, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update({ id, data }: InvoiceArgUpdate) {
|
||||||
|
return this.request(this.$axios.patch<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}`, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setStatus({ id, status }: InvoiceArgStatus) {
|
||||||
|
return this.request(this.$axios.patch<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}/status`, { status }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reissue(id: number) {
|
||||||
|
return this.request(this.$axios.post<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}/reissue`, {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async fetchPdf({ id, lang }: InvoiceArgPdf) {
|
||||||
|
const response = await this.$axios.get<Blob>(`${this.moduleUrl}/${id}/pdf`, { params: { lang }, responseType: 'blob' })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(id: number) {
|
||||||
|
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/${id}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { BillingBankDetails, BillingRequisite, CounterpartyKind } from './billing.types'
|
||||||
|
|
||||||
|
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void'
|
||||||
|
export type InvoiceUnit = 'service' | 'hours' | 'pcs'
|
||||||
|
export type InvoiceDiscountType = 'percent' | 'amount'
|
||||||
|
export type InvoicePaymentTerms = 'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'
|
||||||
|
|
||||||
|
export type InvoiceSellerSnapshot = {
|
||||||
|
name: string
|
||||||
|
legalName: string
|
||||||
|
address: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
logoUrl: string
|
||||||
|
bank: BillingBankDetails
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceCounterpartySnapshot = {
|
||||||
|
kind: CounterpartyKind
|
||||||
|
name: string
|
||||||
|
legalName: string
|
||||||
|
address: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
contactPerson: string
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceLineItem = {
|
||||||
|
id: number
|
||||||
|
taskId: number | null
|
||||||
|
description: string
|
||||||
|
unit: InvoiceUnit
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceLineArg = Omit<InvoiceLineItem, 'id'>
|
||||||
|
|
||||||
|
export type InvoiceTotals = {
|
||||||
|
subtotal: number
|
||||||
|
discount: number
|
||||||
|
taxable: number
|
||||||
|
tax: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceMissingRequisite =
|
||||||
|
| 'seller.name'
|
||||||
|
| 'seller.address'
|
||||||
|
| 'seller.bank'
|
||||||
|
| 'counterparty.name'
|
||||||
|
| 'counterparty.address'
|
||||||
|
| 'lines'
|
||||||
|
|
||||||
|
export type InvoiceTransitionError =
|
||||||
|
| { error: 'not_found' }
|
||||||
|
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
|
||||||
|
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
|
||||||
|
|
||||||
|
export type InvoicePdfLang = 'en' | 'ru'
|
||||||
|
|
||||||
|
export type InvoiceArgPdf = {
|
||||||
|
id: number
|
||||||
|
lang: InvoicePdfLang
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceItem = {
|
||||||
|
id: number
|
||||||
|
organizationId: number
|
||||||
|
goalId: number | null
|
||||||
|
goalName: string
|
||||||
|
sellerId: number
|
||||||
|
counterpartyId: number
|
||||||
|
number: string
|
||||||
|
status: InvoiceStatus
|
||||||
|
reference: string
|
||||||
|
currencyCode: string
|
||||||
|
issueDate: string
|
||||||
|
paymentTerms: InvoicePaymentTerms
|
||||||
|
dueDate: string | null
|
||||||
|
periodFrom: string | null
|
||||||
|
periodTo: string | null
|
||||||
|
discountType: InvoiceDiscountType
|
||||||
|
discountValue: number
|
||||||
|
taxRate: number
|
||||||
|
taxExempt: boolean
|
||||||
|
taxNote: string
|
||||||
|
notes: string
|
||||||
|
terms: string
|
||||||
|
seller: InvoiceSellerSnapshot
|
||||||
|
counterparty: InvoiceCounterpartySnapshot
|
||||||
|
lines: InvoiceLineItem[]
|
||||||
|
totals: InvoiceTotals
|
||||||
|
totalsFrozen: boolean
|
||||||
|
issuedAt: string | null
|
||||||
|
paidAt: string | null
|
||||||
|
voidedAt: string | null
|
||||||
|
replacesInvoiceId: number | null
|
||||||
|
templateVersion: number
|
||||||
|
createdBy: number | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceArgCreate = Omit<
|
||||||
|
InvoiceItem,
|
||||||
|
| 'id' | 'goalName' | 'status' | 'seller' | 'counterparty' | 'lines' | 'createdBy' | 'createdAt' | 'updatedAt'
|
||||||
|
| 'totals' | 'totalsFrozen' | 'issuedAt' | 'paidAt' | 'voidedAt' | 'replacesInvoiceId' | 'templateVersion'
|
||||||
|
> & {
|
||||||
|
lines: InvoiceLineArg[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceArgUpdate = {
|
||||||
|
id: number
|
||||||
|
data: Omit<InvoiceArgCreate, 'organizationId'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceArgList = {
|
||||||
|
organizationId: number
|
||||||
|
includeArchived?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceArgStatus = {
|
||||||
|
id: number
|
||||||
|
status: InvoiceStatus
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export * from '@/api/kanban.types';
|
|||||||
export * from '@/api/integrations.types';
|
export * from '@/api/integrations.types';
|
||||||
export * from '@/api/notifications.api.types';
|
export * from '@/api/notifications.api.types';
|
||||||
export * from '@/api/webhooks.types';
|
export * from '@/api/webhooks.types';
|
||||||
|
export * from '@/api/billing.types';
|
||||||
|
export * from '@/api/invoices.types';
|
||||||
export * from '@/api/messaging.types';
|
export * from '@/api/messaging.types';
|
||||||
export * from '@/api/api-tokens.types';
|
export * from '@/api/api-tokens.types';
|
||||||
export * from '@/api/oauth.types';
|
export * from '@/api/oauth.types';
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import TvTimeTrackingApi from "./api/time-tracking";
|
|||||||
import TvUiPreferencesApi from "./api/ui-preferences";
|
import TvUiPreferencesApi from "./api/ui-preferences";
|
||||||
import TvSprintApi from "./api/sprints";
|
import TvSprintApi from "./api/sprints";
|
||||||
import TvRecurrenceApi from "./api/recurrence";
|
import TvRecurrenceApi from "./api/recurrence";
|
||||||
|
import TvBillingApi from "./api/billing";
|
||||||
|
import TvInvoicesApi from "./api/invoices";
|
||||||
|
|
||||||
export class TvApi {
|
export class TvApi {
|
||||||
|
|
||||||
@@ -68,6 +70,10 @@ export class TvApi {
|
|||||||
|
|
||||||
public recurrence: TvRecurrenceApi;
|
public recurrence: TvRecurrenceApi;
|
||||||
|
|
||||||
|
public billing: TvBillingApi;
|
||||||
|
|
||||||
|
public invoices: TvInvoicesApi;
|
||||||
|
|
||||||
constructor($axios: AxiosInstance) {
|
constructor($axios: AxiosInstance) {
|
||||||
this.$axios = $axios;
|
this.$axios = $axios;
|
||||||
|
|
||||||
@@ -112,6 +118,10 @@ export class TvApi {
|
|||||||
this.sprints = new TvSprintApi(this.$axios);
|
this.sprints = new TvSprintApi(this.$axios);
|
||||||
|
|
||||||
this.recurrence = new TvRecurrenceApi(this.$axios);
|
this.recurrence = new TvRecurrenceApi(this.$axios);
|
||||||
|
|
||||||
|
this.billing = new TvBillingApi(this.$axios);
|
||||||
|
|
||||||
|
this.invoices = new TvInvoicesApi(this.$axios);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setBaseUrl(baseUrl: string) {
|
public setBaseUrl(baseUrl: string) {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export * from './schemas/organizations.schema';
|
|||||||
export * from './schemas/sso.schema';
|
export * from './schemas/sso.schema';
|
||||||
export * from './schemas/time-entries.schema';
|
export * from './schemas/time-entries.schema';
|
||||||
export * from './schemas/time-entries-history.schema';
|
export * from './schemas/time-entries-history.schema';
|
||||||
|
export * from './schemas/currencies.schema';
|
||||||
|
export * from './schemas/billing.schema';
|
||||||
|
export * from './schemas/invoices.schema';
|
||||||
export * from './schemas/ui-preferences.schema';
|
export * from './schemas/ui-preferences.schema';
|
||||||
export * from './schemas/sprints.schema';
|
export * from './schemas/sprints.schema';
|
||||||
export * from './schemas/sprint-task-outcomes.schema';
|
export * from './schemas/sprint-task-outcomes.schema';
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { boolean, char, integer, jsonb, timestamp, varchar } from 'drizzle-orm/pg-core'
|
||||||
|
import { BillingPgSchema, CurrenciesSchema } from './currencies.schema'
|
||||||
|
import { OrganizationsSchema } from './organizations.schema'
|
||||||
|
|
||||||
|
export type BillingRequisite = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingBankDetails = {
|
||||||
|
bankName: string
|
||||||
|
accountNumber: string
|
||||||
|
iban: string
|
||||||
|
swift: string
|
||||||
|
correspondentAccount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SellersSchema = BillingPgSchema.table('sellers', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
|
||||||
|
name: varchar({ length: 200 }).notNull(),
|
||||||
|
legalName: varchar('legal_name', { length: 300 }).notNull().default(''),
|
||||||
|
address: varchar({ length: 1000 }).notNull().default(''),
|
||||||
|
email: varchar({ length: 320 }).notNull().default(''),
|
||||||
|
phone: varchar({ length: 50 }).notNull().default(''),
|
||||||
|
logoUrl: varchar('logo_url', { length: 1000 }).notNull().default(''),
|
||||||
|
currencyCode: char('currency_code', { length: 3 }).notNull().default('USD').references(() => CurrenciesSchema.code),
|
||||||
|
bank: jsonb().$type<BillingBankDetails>().notNull(),
|
||||||
|
requisites: jsonb().$type<BillingRequisite[]>().notNull(),
|
||||||
|
defaultTerms: varchar('default_terms', { length: 2000 }).notNull().default(''),
|
||||||
|
taxNote: varchar('tax_note', { length: 500 }).notNull().default(''),
|
||||||
|
archived: boolean().notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CounterpartiesSchema = BillingPgSchema.table('counterparties', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
|
||||||
|
kind: varchar({ length: 20 }).$type<'organization' | 'person'>().notNull().default('organization'),
|
||||||
|
name: varchar({ length: 200 }).notNull(),
|
||||||
|
legalName: varchar('legal_name', { length: 300 }).notNull().default(''),
|
||||||
|
address: varchar({ length: 1000 }).notNull().default(''),
|
||||||
|
email: varchar({ length: 320 }).notNull().default(''),
|
||||||
|
phone: varchar({ length: 50 }).notNull().default(''),
|
||||||
|
contactPerson: varchar('contact_person', { length: 200 }).notNull().default(''),
|
||||||
|
requisites: jsonb().$type<BillingRequisite[]>().notNull(),
|
||||||
|
archived: boolean().notNull().default(false),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SellersSchemaTypeForSelect = typeof SellersSchema.$inferSelect
|
||||||
|
export type SellersSchemaTypeForInsert = typeof SellersSchema.$inferInsert
|
||||||
|
export type CounterpartiesSchemaTypeForSelect = typeof CounterpartiesSchema.$inferSelect
|
||||||
|
export type CounterpartiesSchemaTypeForInsert = typeof CounterpartiesSchema.$inferInsert
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { boolean, char, integer, pgSchema, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
|
||||||
|
import { createInsertSchema } from 'drizzle-arktype'
|
||||||
|
|
||||||
|
export const BillingPgSchema = pgSchema('tv_billing')
|
||||||
|
|
||||||
|
export const CurrenciesSchema = BillingPgSchema.table('currencies', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
code: char({ length: 3 }).notNull().unique(),
|
||||||
|
numericCode: smallint('numeric_code').notNull().unique(),
|
||||||
|
name: varchar({ length: 64 }).notNull(),
|
||||||
|
symbol: varchar({ length: 8 }).notNull(),
|
||||||
|
decimalDigits: smallint('decimal_digits').notNull().default(2),
|
||||||
|
sortOrder: smallint('sort_order').notNull().default(0),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type CurrenciesSchemaTypeForSelect = typeof CurrenciesSchema.$inferSelect
|
||||||
|
export type CurrenciesSchemaTypeForInsert = typeof CurrenciesSchema.$inferInsert
|
||||||
|
|
||||||
|
export const CurrenciesSchemaArkTypeInsert = createInsertSchema(CurrenciesSchema)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { boolean, char, date, integer, jsonb, numeric, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
|
||||||
|
import { BillingPgSchema, CurrenciesSchema } from './currencies.schema'
|
||||||
|
import { CounterpartiesSchema, SellersSchema, type BillingBankDetails, type BillingRequisite } from './billing.schema'
|
||||||
|
import { OrganizationsSchema } from './organizations.schema'
|
||||||
|
import { GoalsSchema } from './goals.schema'
|
||||||
|
import { TasksSchema } from './tasks.schema'
|
||||||
|
import { UsersSchema } from './users.schema'
|
||||||
|
|
||||||
|
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void'
|
||||||
|
export type InvoiceDiscountType = 'percent' | 'amount'
|
||||||
|
export type InvoicePaymentTerms = 'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'
|
||||||
|
export type InvoiceLineUnit = 'service' | 'hours' | 'pcs'
|
||||||
|
|
||||||
|
export type InvoiceSellerSnapshot = {
|
||||||
|
name: string
|
||||||
|
legalName: string
|
||||||
|
address: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
logoUrl: string
|
||||||
|
bank: BillingBankDetails
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceCounterpartySnapshot = {
|
||||||
|
kind: 'organization' | 'person'
|
||||||
|
name: string
|
||||||
|
legalName: string
|
||||||
|
address: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
contactPerson: string
|
||||||
|
requisites: BillingRequisite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InvoicesSchema = BillingPgSchema.table('invoices', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
|
||||||
|
goalId: integer('goal_id').references(() => GoalsSchema.id, { onDelete: 'set null' }),
|
||||||
|
goalName: varchar('goal_name', { length: 500 }).notNull().default(''),
|
||||||
|
sellerId: integer('seller_id').notNull().references(() => SellersSchema.id, { onDelete: 'restrict' }),
|
||||||
|
counterpartyId: integer('counterparty_id').notNull().references(() => CounterpartiesSchema.id, { onDelete: 'restrict' }),
|
||||||
|
number: varchar({ length: 50 }).notNull(),
|
||||||
|
status: varchar({ length: 10 }).$type<InvoiceStatus>().notNull().default('draft'),
|
||||||
|
reference: varchar({ length: 200 }).notNull().default(''),
|
||||||
|
currencyCode: char('currency_code', { length: 3 }).notNull().references(() => CurrenciesSchema.code),
|
||||||
|
issueDate: date('issue_date').notNull(),
|
||||||
|
paymentTerms: varchar('payment_terms', { length: 20 }).$type<InvoicePaymentTerms>().notNull().default('net14'),
|
||||||
|
dueDate: date('due_date'),
|
||||||
|
periodFrom: date('period_from'),
|
||||||
|
periodTo: date('period_to'),
|
||||||
|
discountType: varchar('discount_type', { length: 10 }).$type<InvoiceDiscountType>().notNull().default('percent'),
|
||||||
|
discountValue: numeric('discount_value', { precision: 12, scale: 2 }).notNull().default('0'),
|
||||||
|
taxRate: numeric('tax_rate', { precision: 5, scale: 2 }).notNull().default('0'),
|
||||||
|
taxExempt: boolean('tax_exempt').notNull().default(false),
|
||||||
|
taxNote: varchar('tax_note', { length: 500 }).notNull().default(''),
|
||||||
|
notes: varchar({ length: 2000 }).notNull().default(''),
|
||||||
|
terms: varchar({ length: 2000 }).notNull().default(''),
|
||||||
|
sellerSnapshot: jsonb('seller_snapshot').$type<InvoiceSellerSnapshot>().notNull(),
|
||||||
|
counterpartySnapshot: jsonb('counterparty_snapshot').$type<InvoiceCounterpartySnapshot>().notNull(),
|
||||||
|
createdBy: integer('created_by').references(() => UsersSchema.id, { onDelete: 'set null' }),
|
||||||
|
issuedAt: timestamp('issued_at'),
|
||||||
|
paidAt: timestamp('paid_at'),
|
||||||
|
voidedAt: timestamp('voided_at'),
|
||||||
|
replacesInvoiceId: integer('replaces_invoice_id'),
|
||||||
|
templateVersion: smallint('template_version').notNull().default(1),
|
||||||
|
subtotal: numeric({ precision: 12, scale: 2 }),
|
||||||
|
discountAmount: numeric('discount_amount', { precision: 12, scale: 2 }),
|
||||||
|
taxAmount: numeric('tax_amount', { precision: 12, scale: 2 }),
|
||||||
|
total: numeric({ precision: 12, scale: 2 }),
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const InvoiceLinesSchema = BillingPgSchema.table('invoice_lines', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
invoiceId: integer('invoice_id').notNull().references(() => InvoicesSchema.id, { onDelete: 'cascade' }),
|
||||||
|
position: smallint().notNull().default(0),
|
||||||
|
taskId: integer('task_id').references(() => TasksSchema.id, { onDelete: 'set null' }),
|
||||||
|
description: varchar({ length: 1000 }).notNull(),
|
||||||
|
unit: varchar({ length: 20 }).$type<InvoiceLineUnit>().notNull().default('service'),
|
||||||
|
quantity: numeric({ precision: 12, scale: 2 }).notNull().default('1'),
|
||||||
|
unitPrice: numeric('unit_price', { precision: 12, scale: 2 }).notNull().default('0'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type InvoicesSchemaTypeForSelect = typeof InvoicesSchema.$inferSelect
|
||||||
|
export type InvoicesSchemaTypeForInsert = typeof InvoicesSchema.$inferInsert
|
||||||
|
export type InvoiceLinesSchemaTypeForSelect = typeof InvoiceLinesSchema.$inferSelect
|
||||||
|
export type InvoiceLinesSchemaTypeForInsert = typeof InvoiceLinesSchema.$inferInsert
|
||||||
@@ -108,6 +108,26 @@ function buildRoutes(extensions: TvWebExtension[]): RouteRecordRaw[] {
|
|||||||
name: 'analytics',
|
name: 'analytics',
|
||||||
component: () => import('./pages/user/analytics.vue'),
|
component: () => import('./pages/user/analytics.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'invoices',
|
||||||
|
name: 'invoices',
|
||||||
|
component: () => import('./pages/user/invoices.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invoices/counterparties',
|
||||||
|
name: 'invoices-counterparties',
|
||||||
|
component: () => import('./pages/user/invoices-counterparties.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invoices/sellers',
|
||||||
|
name: 'invoices-sellers',
|
||||||
|
component: () => import('./pages/user/invoices-sellers.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invoices/:invoiceId(\\d+)',
|
||||||
|
name: 'invoice-preview',
|
||||||
|
component: () => import('./pages/user/invoice-preview.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ui-customization',
|
path: 'ui-customization',
|
||||||
name: 'ui-customization',
|
name: 'ui-customization',
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'draft'"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
:label="compact ? undefined : t('common.edit')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="emit('edit')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'draft'"
|
||||||
|
icon="i-lucide-send"
|
||||||
|
:label="compact ? undefined : t('invoices.actions.issue')"
|
||||||
|
variant="soft"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('transition', 'issued')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'issued'"
|
||||||
|
icon="i-lucide-badge-check"
|
||||||
|
:label="compact ? undefined : t('invoices.actions.markPaid')"
|
||||||
|
variant="soft"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('transition', 'paid')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'issued'"
|
||||||
|
icon="i-lucide-file-pen-line"
|
||||||
|
:label="compact ? undefined : t('invoices.actions.reissue')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('reissue')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'issued'"
|
||||||
|
icon="i-lucide-ban"
|
||||||
|
:label="compact ? undefined : t('invoices.actions.void')"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('transition', 'void')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'paid'"
|
||||||
|
icon="i-lucide-undo-2"
|
||||||
|
:label="compact ? undefined : t('invoices.actions.unmarkPaid')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('transition', 'issued')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice.status === 'void'"
|
||||||
|
icon="i-lucide-file-pen-line"
|
||||||
|
:label="compact ? undefined : t('invoices.actions.reissue')"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:loading="busy"
|
||||||
|
@click="emit('reissue')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { InvoiceItem, InvoiceStatus } from 'taskview-api'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
invoice: InvoiceItem
|
||||||
|
compact: boolean
|
||||||
|
busy: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
transition: [status: InvoiceStatus]
|
||||||
|
reissue: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-[60vh] flex-col">
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex flex-1 items-center justify-center text-muted"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-loader-circle"
|
||||||
|
class="size-8 animate-spin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else-if="!url"
|
||||||
|
class="py-16 text-center text-sm text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.preview.pdfFailed') }}
|
||||||
|
</p>
|
||||||
|
<iframe
|
||||||
|
v-else
|
||||||
|
:src="url"
|
||||||
|
:title="title"
|
||||||
|
class="h-full min-h-[70vh] w-full flex-1 rounded-10 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { InvoicePdfLang } from 'taskview-api'
|
||||||
|
import { useInvoicesStore } from '@/stores/invoices.store'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
invoiceId: number
|
||||||
|
title: string
|
||||||
|
version: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
loaded: [blob: Blob | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const invoicesStore = useInvoicesStore()
|
||||||
|
|
||||||
|
const url = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
if (url.value) URL.revokeObjectURL(url.value.split('#')[0])
|
||||||
|
url.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
const lang: InvoicePdfLang = locale.value === 'ru' ? 'ru' : 'en'
|
||||||
|
const blob = await invoicesStore.fetchPdf(props.invoiceId, lang)
|
||||||
|
release()
|
||||||
|
if (blob) url.value = `${URL.createObjectURL(blob)}#navpanes=0&view=FitH`
|
||||||
|
loading.value = false
|
||||||
|
emit('loaded', blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.invoiceId, props.version, locale.value], load, { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(release)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-muted">
|
||||||
|
<UBadge
|
||||||
|
:label="t(`invoices.status.${invoice.status}`)"
|
||||||
|
:color="statusColors[invoice.status]"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
v-if="overdue"
|
||||||
|
:label="t('invoices.overdue')"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
<span v-if="invoice.issuedAt">{{ t('invoices.dates.issued') }} {{ date(invoice.issuedAt) }}</span>
|
||||||
|
<span v-if="invoice.paidAt">· {{ t('invoices.dates.paid') }} {{ date(invoice.paidAt) }}</span>
|
||||||
|
<span v-if="invoice.voidedAt">· {{ t('invoices.dates.voided') }} {{ date(invoice.voidedAt) }}</span>
|
||||||
|
<RouterLink
|
||||||
|
v-if="invoice.replacesInvoiceId !== null"
|
||||||
|
:to="{ name: 'invoice-preview', params: { invoiceId: invoice.replacesInvoiceId } }"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{{ t('invoices.replacesLink') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useDateFormat } from '@vueuse/core'
|
||||||
|
import type { InvoiceItem } from 'taskview-api'
|
||||||
|
import { isInvoiceOverdue } from '@/helpers/invoiceDates'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
invoice: InvoiceItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const statusColors = { draft: 'warning', issued: 'info', paid: 'success', void: 'neutral' } as const
|
||||||
|
const overdue = computed(() => isInvoiceOverdue(props.invoice))
|
||||||
|
|
||||||
|
function date(value: string): string {
|
||||||
|
return useDateFormat(new Date(value), 'DD MMM YYYY').value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-receipt"
|
||||||
|
class="size-12 text-muted"
|
||||||
|
/>
|
||||||
|
<p class="font-medium text-default">
|
||||||
|
{{ t('invoices.page.empty') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted max-w-md">
|
||||||
|
{{ t('invoices.page.emptyHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 p-2 lg:p-6">
|
||||||
|
<InvoicesListItem
|
||||||
|
v-for="invoice in invoices"
|
||||||
|
:key="invoice.id"
|
||||||
|
:invoice="invoice"
|
||||||
|
@open="emit('open', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import InvoicesListItem from './InvoicesListItem.vue'
|
||||||
|
import type { InvoiceItem } from 'taskview-api'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
invoices: InvoiceItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [invoice: InvoiceItem]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-default px-4 py-3 text-left hover:bg-elevated"
|
||||||
|
@click="emit('open', invoice)"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-receipt"
|
||||||
|
class="size-5 shrink-0 text-muted"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 basis-52 flex-col">
|
||||||
|
<span class="truncate font-medium text-default">
|
||||||
|
{{ invoice.number }} · {{ invoice.counterparty.name }}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-xs text-muted">
|
||||||
|
{{ invoice.seller.name }} · {{ invoice.goalName }} · {{ issueDate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<UBadge
|
||||||
|
v-if="invoice.replacesInvoiceId !== null"
|
||||||
|
:label="t('invoices.replaces')"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
v-if="overdue"
|
||||||
|
:label="t('invoices.overdue')"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
:label="t(`invoices.status.${invoice.status}`)"
|
||||||
|
:color="statusColors[invoice.status]"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span class="shrink-0 pl-1 text-sm font-semibold tabular-nums text-default">
|
||||||
|
{{ formatMoney({ amount: invoice.totals.total, currencyCode: invoice.currencyCode, locale }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useDateFormat } from '@vueuse/core'
|
||||||
|
import { formatMoney } from '@/helpers/money'
|
||||||
|
import { isInvoiceOverdue } from '@/helpers/invoiceDates'
|
||||||
|
import type { InvoiceItem } from 'taskview-api'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
invoice: InvoiceItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [invoice: InvoiceItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const statusColors = { draft: 'warning', issued: 'info', paid: 'success', void: 'neutral' } as const
|
||||||
|
|
||||||
|
const issueDate = computed(() => useDateFormat(new Date(props.invoice.issueDate), 'DD MMM YYYY').value)
|
||||||
|
const overdue = computed(() => isInvoiceOverdue(props.invoice))
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center">
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-lock"
|
||||||
|
class="size-10 text-muted"
|
||||||
|
/>
|
||||||
|
<p class="font-medium text-default">
|
||||||
|
{{ t('invoices.page.noPermission') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted max-w-md">
|
||||||
|
{{ t('invoices.page.noPermissionHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<UDashboardPanel :id="id">
|
||||||
|
<template #header>
|
||||||
|
<UDashboardNavbar :title="title">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse />
|
||||||
|
</template>
|
||||||
|
<template #right>
|
||||||
|
<slot name="actions" />
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
v-if="showTabs"
|
||||||
|
class="flex flex-col border-b border-default px-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4 lg:px-6"
|
||||||
|
>
|
||||||
|
<div class="overflow-x-auto [scrollbar-width:none]">
|
||||||
|
<UNavigationMenu
|
||||||
|
:items="tabs"
|
||||||
|
class="w-max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<USwitch
|
||||||
|
v-if="includeArchived !== undefined"
|
||||||
|
v-model="includeArchived"
|
||||||
|
:label="t('invoices.showArchived')"
|
||||||
|
size="sm"
|
||||||
|
class="self-end pb-2 lg:self-auto lg:pb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useCurrenciesStore } from '@/stores/currencies.store'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
showTabs?: boolean
|
||||||
|
}>(),
|
||||||
|
{ showTabs: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const includeArchived = defineModel<boolean | undefined>('includeArchived', { default: undefined })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const currenciesStore = useCurrenciesStore()
|
||||||
|
|
||||||
|
onMounted(() => currenciesStore.fetch())
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ label: t('invoices.page.title'), icon: 'i-lucide-receipt', to: { name: 'invoices' } },
|
||||||
|
{ label: t('invoices.counterparty.title'), icon: 'i-lucide-users', to: { name: 'invoices-counterparties' } },
|
||||||
|
{ label: t('invoices.seller.title'), icon: 'i-lucide-building-2', to: { name: 'invoices-sellers' } },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex w-full flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-default px-4 py-3">
|
||||||
|
<UIcon
|
||||||
|
:name="icon"
|
||||||
|
class="size-5 shrink-0 text-muted"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-1 basis-52 flex-col">
|
||||||
|
<span class="flex items-center gap-2 truncate font-medium text-default">
|
||||||
|
{{ title }}
|
||||||
|
<UBadge
|
||||||
|
v-if="archived"
|
||||||
|
:label="t('invoices.archivedBadge')"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-xs text-muted">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('edit')"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:icon="archived ? 'i-lucide-archive-restore' : 'i-lucide-archive'"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:title="archived ? t('invoices.unarchive') : t('invoices.archive')"
|
||||||
|
@click="emit('archive', !archived)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('delete')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
archived: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
archive: [archived: boolean]
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="sectionClass">
|
||||||
|
<span class="text-sm font-medium text-default">{{ t('invoices.bank.title') }}</span>
|
||||||
|
<UFormField :label="t('invoices.bank.bankName')">
|
||||||
|
<UInput
|
||||||
|
v-model="model.bankName"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.bank.accountNumber')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.accountNumber"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.bank.iban')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.iban"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.bank.swift')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.swift"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.bank.correspondentAccount')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.correspondentAccount"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import type { BillingBankDetails } from 'taskview-api'
|
||||||
|
|
||||||
|
const model = defineModel<BillingBankDetails>({ required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi, sectionClass } = useInvoiceFieldStyle()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<UFormField :label="t('invoices.counterparty.kind')">
|
||||||
|
<UTabs
|
||||||
|
v-model="model.kind"
|
||||||
|
:items="kindItems"
|
||||||
|
:content="false"
|
||||||
|
size="lg"
|
||||||
|
class="w-full lg:w-80"
|
||||||
|
:ui="{ list: 'rounded-14', trigger: 'rounded-10', indicator: 'rounded-10' }"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<PartyFields v-model="model" />
|
||||||
|
|
||||||
|
<UFormField :label="t('invoices.counterparty.contactPerson')">
|
||||||
|
<UInput
|
||||||
|
v-model="model.contactPerson"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import PartyFields from './PartyFields.vue'
|
||||||
|
import type { CounterpartyKind } from 'taskview-api'
|
||||||
|
import type { CounterpartyFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<CounterpartyFormValue>({ required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const kindItems = computed(() => [
|
||||||
|
{ label: t('invoices.counterparty.kinds.organization'), value: 'organization' as CounterpartyKind },
|
||||||
|
{ label: t('invoices.counterparty.kinds.person'), value: 'person' as CounterpartyKind },
|
||||||
|
])
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
v-model:open="open"
|
||||||
|
:fullscreen="isMobile"
|
||||||
|
:ui="{ content: isMobile ? 'flex flex-col' : 'lg:max-w-2xl max-h-[90vh] flex flex-col' }"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<UCard :ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ isEdit ? t('invoices.counterparty.editTitle') : t('invoices.counterparty.create') }}
|
||||||
|
</h3>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CounterpartyForm v-model="formValue" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span
|
||||||
|
v-if="!canSubmit"
|
||||||
|
class="mr-auto text-xs text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.validation.fillIn') }}: {{ t('invoices.party.name') }}
|
||||||
|
</span>
|
||||||
|
<UButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:label="isEdit ? t('common.save') : t('invoices.counterparty.create')"
|
||||||
|
variant="soft"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import CounterpartyForm from './CounterpartyForm.vue'
|
||||||
|
import { useTaskView } from '@/composables/useTaskView'
|
||||||
|
import { useCounterpartiesStore } from '@/stores/counterparties.store'
|
||||||
|
import type { CounterpartyItem } from 'taskview-api'
|
||||||
|
import type { CounterpartyFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
organizationId: number
|
||||||
|
counterparty?: CounterpartyItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: [counterparty: CounterpartyItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { isMobile } = useTaskView()
|
||||||
|
const counterpartiesStore = useCounterpartiesStore()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.counterparty)
|
||||||
|
|
||||||
|
function emptyValue(): CounterpartyFormValue {
|
||||||
|
return { kind: 'organization', name: '', legalName: '', address: '', email: '', phone: '', contactPerson: '', requisites: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCounterparty(counterparty: CounterpartyItem): CounterpartyFormValue {
|
||||||
|
return {
|
||||||
|
kind: counterparty.kind,
|
||||||
|
name: counterparty.name,
|
||||||
|
legalName: counterparty.legalName,
|
||||||
|
address: counterparty.address,
|
||||||
|
email: counterparty.email,
|
||||||
|
phone: counterparty.phone,
|
||||||
|
contactPerson: counterparty.contactPerson,
|
||||||
|
requisites: counterparty.requisites.map((item) => ({ ...item })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formValue = ref<CounterpartyFormValue>(props.counterparty ? fromCounterparty(props.counterparty) : emptyValue())
|
||||||
|
const canSubmit = computed(() => formValue.value.name.trim().length > 0)
|
||||||
|
|
||||||
|
watch(open, (value) => {
|
||||||
|
if (value) formValue.value = props.counterparty ? fromCounterparty(props.counterparty) : emptyValue()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
const counterparty = isEdit.value && props.counterparty
|
||||||
|
? await counterpartiesStore.updateCounterparty({ counterpartyId: props.counterparty.id, value: formValue.value })
|
||||||
|
: await counterpartiesStore.createCounterparty({ organizationId: props.organizationId, value: formValue.value })
|
||||||
|
if (!counterparty) {
|
||||||
|
toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.add({
|
||||||
|
title: t(isEdit.value ? 'invoices.counterparty.toasts.updated' : 'invoices.counterparty.toasts.created'),
|
||||||
|
color: 'success',
|
||||||
|
})
|
||||||
|
emit('saved', counterparty)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<UFormField
|
||||||
|
:label="label"
|
||||||
|
:required="required"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UPopover v-model:open="open">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-calendar"
|
||||||
|
:label="formatted"
|
||||||
|
color="neutral"
|
||||||
|
:variant="dateButtonVariant"
|
||||||
|
size="xl"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="w-full"
|
||||||
|
:ui="dateButtonUi"
|
||||||
|
/>
|
||||||
|
<template #content>
|
||||||
|
<UCalendar
|
||||||
|
v-model="dateModel"
|
||||||
|
:min-value="toCalendarDate(minDate ?? null)"
|
||||||
|
:week-starts-on="weekStart"
|
||||||
|
class="p-2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
</UFormField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { useDateFormat } from '@vueuse/core'
|
||||||
|
import { CalendarDate } from '@internationalized/date'
|
||||||
|
import { useWeekStart } from '@/composables/useWeekStart'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
|
||||||
|
const model = defineModel<string | null>({ required: true })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
minDate?: string | null
|
||||||
|
disabled?: boolean
|
||||||
|
required?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const weekStart = useWeekStart()
|
||||||
|
const { dateButtonVariant, dateButtonUi } = useInvoiceFieldStyle()
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
function toCalendarDate(value: string | null): CalendarDate | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
if (!year || !month || !day) return undefined
|
||||||
|
return new CalendarDate(year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateModel = shallowRef<CalendarDate | undefined>(toCalendarDate(model.value))
|
||||||
|
|
||||||
|
watch(dateModel, (value) => {
|
||||||
|
model.value = value ? value.toString() : null
|
||||||
|
if (value) open.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(model, (value) => {
|
||||||
|
const next = toCalendarDate(value)
|
||||||
|
if (next?.toString() !== dateModel.value?.toString()) dateModel.value = next
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatted = computed(() =>
|
||||||
|
dateModel.value
|
||||||
|
? useDateFormat(new Date(dateModel.value.toString()), 'DD MMM YYYY').value
|
||||||
|
: props.label,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<InvoiceFormHeader
|
||||||
|
v-model="model"
|
||||||
|
:projects="projects"
|
||||||
|
:sellers="sellers"
|
||||||
|
:counterparties="counterparties"
|
||||||
|
@select-project="selectProject"
|
||||||
|
@select-seller="selectSeller"
|
||||||
|
@create-seller="emit('create-seller')"
|
||||||
|
@create-counterparty="emit('create-counterparty')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InvoiceFormDates v-model="model" />
|
||||||
|
|
||||||
|
<InvoiceTaskPicker
|
||||||
|
:goal-id="model.goalId"
|
||||||
|
:currency-code="model.currencyCode"
|
||||||
|
:selected-ids="selectedTaskIds"
|
||||||
|
@toggle="toggleTask"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InvoiceLinesEditor
|
||||||
|
v-model="model.lines"
|
||||||
|
:currency-code="model.currencyCode"
|
||||||
|
@add="addManualLine"
|
||||||
|
@remove="removeLine"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InvoiceTotalsEditor v-model="model" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.terms')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UTextarea
|
||||||
|
v-model="model.terms"
|
||||||
|
:placeholder="t('invoices.fields.termsPlaceholder')"
|
||||||
|
:rows="2"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.notes')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UTextarea
|
||||||
|
v-model="model.notes"
|
||||||
|
:placeholder="t('invoices.fields.notesPlaceholder')"
|
||||||
|
:rows="2"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import InvoiceFormHeader from './InvoiceFormHeader.vue'
|
||||||
|
import InvoiceFormDates from './InvoiceFormDates.vue'
|
||||||
|
import InvoiceTaskPicker from './InvoiceTaskPicker.vue'
|
||||||
|
import InvoiceLinesEditor from './InvoiceLinesEditor.vue'
|
||||||
|
import InvoiceTotalsEditor from './InvoiceTotalsEditor.vue'
|
||||||
|
import { useSellersStore } from '@/stores/sellers.store'
|
||||||
|
import { useInvoicesStore } from '@/stores/invoices.store'
|
||||||
|
import type { InvoiceFormValue, InvoiceSelectOption, InvoiceTaskOption } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<InvoiceFormValue>({ required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
projects: InvoiceSelectOption[]
|
||||||
|
sellers: InvoiceSelectOption[]
|
||||||
|
counterparties: InvoiceSelectOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'create-seller': []
|
||||||
|
'create-counterparty': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi } = useInvoiceFieldStyle()
|
||||||
|
const sellersStore = useSellersStore()
|
||||||
|
const invoicesStore = useInvoicesStore()
|
||||||
|
|
||||||
|
const selectedTaskIds = computed(
|
||||||
|
() => new Set(model.value.lines.filter((line) => line.taskId !== null).map((line) => line.taskId as number)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectProject(goalId: number | null) {
|
||||||
|
if (goalId === model.value.goalId) return
|
||||||
|
model.value.goalId = goalId
|
||||||
|
model.value.lines = model.value.lines.filter((line) => line.taskId === null)
|
||||||
|
if (goalId !== null && model.value.counterpartyId === null) {
|
||||||
|
model.value.counterpartyId = invoicesStore.lastCounterpartyForGoal(goalId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSeller(sellerId: number | null) {
|
||||||
|
model.value.sellerId = sellerId
|
||||||
|
const seller = sellerId === null ? null : sellersStore.byId(sellerId)
|
||||||
|
if (!seller) return
|
||||||
|
model.value.currencyCode = seller.currencyCode
|
||||||
|
if (!model.value.terms.trim()) model.value.terms = seller.defaultTerms
|
||||||
|
if (!model.value.taxNote.trim()) model.value.taxNote = seller.taxNote
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTask(task: InvoiceTaskOption) {
|
||||||
|
const index = model.value.lines.findIndex((line) => line.taskId === task.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
model.value.lines.splice(index, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.value.lines.push({
|
||||||
|
key: `task-${task.id}`,
|
||||||
|
taskId: task.id,
|
||||||
|
description: task.description,
|
||||||
|
unit: 'service',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: task.amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addManualLine() {
|
||||||
|
model.value.lines.push({
|
||||||
|
key: `manual-${Date.now()}-${model.value.lines.length}`,
|
||||||
|
taskId: null,
|
||||||
|
description: '',
|
||||||
|
unit: 'service',
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: string) {
|
||||||
|
model.value.lines = model.value.lines.filter((line) => line.key !== key)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<InvoiceDateField
|
||||||
|
v-model="model.issueDate"
|
||||||
|
:label="t('invoices.fields.issueDate')"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.paymentTerms')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<USelect
|
||||||
|
v-model="model.paymentTerms"
|
||||||
|
:items="termsItems"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<InvoiceDateField
|
||||||
|
v-model="model.dueDate"
|
||||||
|
:label="t('invoices.fields.dueDate')"
|
||||||
|
:min-date="model.issueDate"
|
||||||
|
:disabled="model.paymentTerms !== 'custom'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<InvoiceDateField
|
||||||
|
v-model="model.periodFrom"
|
||||||
|
:label="t('invoices.fields.periodFrom')"
|
||||||
|
/>
|
||||||
|
<InvoiceDateField
|
||||||
|
v-model="model.periodTo"
|
||||||
|
:label="t('invoices.fields.periodTo')"
|
||||||
|
:min-date="model.periodFrom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import InvoiceDateField from './InvoiceDateField.vue'
|
||||||
|
import { addDays } from '@/helpers/invoiceDates'
|
||||||
|
import type { InvoicePaymentTerms } from 'taskview-api'
|
||||||
|
import { PAYMENT_TERMS_DAYS, type InvoiceFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<InvoiceFormValue>({ required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi } = useInvoiceFieldStyle()
|
||||||
|
|
||||||
|
const termsItems = computed(() =>
|
||||||
|
(Object.keys(PAYMENT_TERMS_DAYS) as InvoicePaymentTerms[]).map((value) => ({
|
||||||
|
label: t(`invoices.paymentTerms.${value}`),
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [model.value.issueDate, model.value.paymentTerms] as const,
|
||||||
|
([issueDate, terms]) => {
|
||||||
|
const days = PAYMENT_TERMS_DAYS[terms]
|
||||||
|
if (days === null || !issueDate) return
|
||||||
|
model.value.dueDate = addDays({ date: issueDate, days })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.number')"
|
||||||
|
required
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.number"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.reference')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.reference"
|
||||||
|
:placeholder="t('invoices.fields.referencePlaceholder')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.currency')"
|
||||||
|
class="lg:w-40"
|
||||||
|
>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="model.currencyCode"
|
||||||
|
:items="currencyItems"
|
||||||
|
value-key="value"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.seller.title')"
|
||||||
|
required
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<USelectMenu
|
||||||
|
:model-value="model.sellerId ?? undefined"
|
||||||
|
:items="sellers"
|
||||||
|
value-key="value"
|
||||||
|
:placeholder="t('invoices.fields.sellerPlaceholder')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="emit('select-seller', $event ?? null)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
color="neutral"
|
||||||
|
:variant="inputVariant"
|
||||||
|
size="xl"
|
||||||
|
:ui="inputUi"
|
||||||
|
@click="emit('create-seller')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.counterparty.title')"
|
||||||
|
required
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<USelectMenu
|
||||||
|
:model-value="model.counterpartyId ?? undefined"
|
||||||
|
:items="counterparties"
|
||||||
|
value-key="value"
|
||||||
|
:placeholder="t('invoices.fields.counterpartyPlaceholder')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="model.counterpartyId = $event ?? null"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
color="neutral"
|
||||||
|
:variant="inputVariant"
|
||||||
|
size="xl"
|
||||||
|
:ui="inputUi"
|
||||||
|
@click="emit('create-counterparty')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.project')"
|
||||||
|
required
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<USelectMenu
|
||||||
|
:model-value="model.goalId ?? undefined"
|
||||||
|
:items="projects"
|
||||||
|
value-key="value"
|
||||||
|
:placeholder="t('invoices.fields.project')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="emit('select-project', $event ?? null)"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useCurrenciesStore } from '@/stores/currencies.store'
|
||||||
|
import type { InvoiceFormValue, InvoiceSelectOption } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<InvoiceFormValue>({ required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
projects: InvoiceSelectOption[]
|
||||||
|
sellers: InvoiceSelectOption[]
|
||||||
|
counterparties: InvoiceSelectOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-project': [goalId: number | null]
|
||||||
|
'select-seller': [sellerId: number | null]
|
||||||
|
'create-seller': []
|
||||||
|
'create-counterparty': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi } = useInvoiceFieldStyle()
|
||||||
|
|
||||||
|
const { options: currencyItems } = storeToRefs(useCurrenciesStore())
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
v-model:open="open"
|
||||||
|
:fullscreen="isMobile"
|
||||||
|
:ui="{ content: isMobile ? 'flex flex-col' : 'lg:max-w-4xl max-h-[90vh] flex flex-col' }"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<UCard :ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ isEdit ? t('invoices.editTitle') : t('invoices.create') }}
|
||||||
|
</h3>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<InvoiceForm
|
||||||
|
v-model="formValue"
|
||||||
|
:projects="projects"
|
||||||
|
:sellers="sellerOptions"
|
||||||
|
:counterparties="counterpartyOptions"
|
||||||
|
@create-seller="sellerModalOpen = true"
|
||||||
|
@create-counterparty="counterpartyModalOpen = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span
|
||||||
|
v-if="missing.length > 0"
|
||||||
|
class="mr-auto text-xs text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.validation.fillIn') }}: {{ missing.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<UButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:label="isEdit ? t('common.save') : t('invoices.create')"
|
||||||
|
variant="soft"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="saving"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<SellerFormModal
|
||||||
|
v-model:open="sellerModalOpen"
|
||||||
|
:organization-id="organizationId"
|
||||||
|
@saved="formValue.sellerId = $event.id"
|
||||||
|
/>
|
||||||
|
<CounterpartyFormModal
|
||||||
|
v-model:open="counterpartyModalOpen"
|
||||||
|
:organization-id="organizationId"
|
||||||
|
@saved="formValue.counterpartyId = $event.id"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import InvoiceForm from './InvoiceForm.vue'
|
||||||
|
import SellerFormModal from './SellerFormModal.vue'
|
||||||
|
import CounterpartyFormModal from './CounterpartyFormModal.vue'
|
||||||
|
import { useTaskView } from '@/composables/useTaskView'
|
||||||
|
import { useInvoicesStore } from '@/stores/invoices.store'
|
||||||
|
import { useSellersStore } from '@/stores/sellers.store'
|
||||||
|
import { useCounterpartiesStore } from '@/stores/counterparties.store'
|
||||||
|
import { todayIso } from '@/helpers/invoiceDates'
|
||||||
|
import { useInvoiceValidation } from '@/composables/useInvoiceValidation'
|
||||||
|
import type { InvoiceItem } from 'taskview-api'
|
||||||
|
import type { InvoiceFormValue, InvoiceSelectOption } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
organizationId: number
|
||||||
|
projects: InvoiceSelectOption[]
|
||||||
|
invoice?: InvoiceItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: [invoice: InvoiceItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { isMobile } = useTaskView()
|
||||||
|
const invoicesStore = useInvoicesStore()
|
||||||
|
const sellersStore = useSellersStore()
|
||||||
|
const counterpartiesStore = useCounterpartiesStore()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.invoice)
|
||||||
|
const sellerModalOpen = ref(false)
|
||||||
|
const counterpartyModalOpen = ref(false)
|
||||||
|
|
||||||
|
const sellerOptions = computed<InvoiceSelectOption[]>(() =>
|
||||||
|
sellersStore.active.map((seller) => ({ label: seller.name, value: seller.id })),
|
||||||
|
)
|
||||||
|
const counterpartyOptions = computed<InvoiceSelectOption[]>(() =>
|
||||||
|
counterpartiesStore.active.map((item) => ({ label: item.name, value: item.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function emptyValue(): InvoiceFormValue {
|
||||||
|
const sellers = sellersStore.active
|
||||||
|
const seller = sellers.length === 1 ? sellers[0] : null
|
||||||
|
return {
|
||||||
|
number: invoicesStore.suggestedNumber(),
|
||||||
|
reference: '',
|
||||||
|
goalId: props.projects.length === 1 ? props.projects[0].value : null,
|
||||||
|
sellerId: seller?.id ?? null,
|
||||||
|
counterpartyId: null,
|
||||||
|
currencyCode: seller?.currencyCode ?? 'USD',
|
||||||
|
issueDate: todayIso(),
|
||||||
|
paymentTerms: 'net14',
|
||||||
|
dueDate: null,
|
||||||
|
periodFrom: null,
|
||||||
|
periodTo: null,
|
||||||
|
lines: [],
|
||||||
|
discountType: 'percent',
|
||||||
|
discountValue: 0,
|
||||||
|
taxRate: 0,
|
||||||
|
taxExempt: false,
|
||||||
|
taxNote: seller?.taxNote ?? '',
|
||||||
|
notes: '',
|
||||||
|
terms: seller?.defaultTerms ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromInvoice(invoice: InvoiceItem): InvoiceFormValue {
|
||||||
|
return {
|
||||||
|
number: invoice.number,
|
||||||
|
reference: invoice.reference,
|
||||||
|
goalId: invoice.goalId,
|
||||||
|
sellerId: invoice.sellerId,
|
||||||
|
counterpartyId: invoice.counterpartyId,
|
||||||
|
currencyCode: invoice.currencyCode,
|
||||||
|
issueDate: invoice.issueDate,
|
||||||
|
paymentTerms: invoice.paymentTerms,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
periodFrom: invoice.periodFrom,
|
||||||
|
periodTo: invoice.periodTo,
|
||||||
|
discountType: invoice.discountType,
|
||||||
|
discountValue: invoice.discountValue,
|
||||||
|
taxRate: invoice.taxRate,
|
||||||
|
taxExempt: invoice.taxExempt,
|
||||||
|
taxNote: invoice.taxNote,
|
||||||
|
notes: invoice.notes,
|
||||||
|
terms: invoice.terms,
|
||||||
|
lines: invoice.lines.map((line) => ({
|
||||||
|
key: line.taskId !== null ? `task-${line.taskId}` : `manual-${line.id}`,
|
||||||
|
taskId: line.taskId,
|
||||||
|
description: line.description,
|
||||||
|
unit: line.unit,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formValue = ref<InvoiceFormValue>(props.invoice ? fromInvoice(props.invoice) : emptyValue())
|
||||||
|
const { missing, canSubmit } = useInvoiceValidation(formValue)
|
||||||
|
|
||||||
|
watch(open, async (value) => {
|
||||||
|
if (!value) return
|
||||||
|
await Promise.all([
|
||||||
|
sellersStore.sellers.length ? null : sellersStore.fetch(props.organizationId),
|
||||||
|
counterpartiesStore.counterparties.length ? null : counterpartiesStore.fetch(props.organizationId),
|
||||||
|
])
|
||||||
|
formValue.value = props.invoice ? fromInvoice(props.invoice) : emptyValue()
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value || saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
const result = isEdit.value && props.invoice
|
||||||
|
? await invoicesStore.updateInvoice({ invoiceId: props.invoice.id, value: formValue.value })
|
||||||
|
: await invoicesStore.createInvoice({ organizationId: props.organizationId, value: formValue.value })
|
||||||
|
saving.value = false
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
const key = result.error === 'duplicate_number' ? 'duplicateNumber' : result.error === 'not_draft' ? 'notDraft' : 'saveFailed'
|
||||||
|
toast.add({ title: t(`invoices.toasts.${key}`), color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.add({ title: t(isEdit.value ? 'invoices.toasts.updated' : 'invoices.toasts.created'), color: 'success' })
|
||||||
|
emit('saved', result.invoice)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="sectionClass">
|
||||||
|
<span class="text-sm font-medium text-default">{{ t('invoices.lines.title') }} <span class="text-error">*</span></span>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="lines.length === 0"
|
||||||
|
class="py-3 text-sm text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.lines.empty') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="hidden lg:grid lg:grid-cols-[1fr_9rem_5.5rem_7.5rem_6.5rem_2.5rem] gap-2 px-1 text-xs text-muted"
|
||||||
|
>
|
||||||
|
<span>{{ t('invoices.lines.description') }}</span>
|
||||||
|
<span>{{ t('invoices.lines.unit') }}</span>
|
||||||
|
<span>{{ t('invoices.lines.quantity') }}</span>
|
||||||
|
<span>{{ t('invoices.lines.price') }}</span>
|
||||||
|
<span class="text-right">{{ t('invoices.lines.amount') }}</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="line in lines"
|
||||||
|
:key="line.key"
|
||||||
|
class="grid grid-cols-[1fr_auto] gap-2 rounded-10 bg-elevated/40 p-2 lg:grid-cols-[1fr_9rem_5.5rem_7.5rem_6.5rem_2.5rem] lg:items-center lg:bg-transparent lg:p-0"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="line.description"
|
||||||
|
:placeholder="t('invoices.lines.description')"
|
||||||
|
:disabled="line.taskId !== null"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="lg:order-last"
|
||||||
|
@click="emit('remove', line.key)"
|
||||||
|
/>
|
||||||
|
<div class="col-span-2 grid grid-cols-[1.4fr_1fr_1fr] gap-2 lg:contents">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.lines.unit')"
|
||||||
|
:ui="fieldUi"
|
||||||
|
>
|
||||||
|
<USelect
|
||||||
|
v-model="line.unit"
|
||||||
|
:items="unitItems"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.lines.quantity')"
|
||||||
|
:ui="fieldUi"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="String(line.quantity)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="line.quantity = toNumber($event)"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.lines.price')"
|
||||||
|
:ui="fieldUi"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="String(line.unitPrice)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="line.unitPrice = toNumber($event)"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
<span class="col-span-2 text-right text-sm tabular-nums text-default lg:col-span-1">
|
||||||
|
{{ formatMoney({ amount: lineAmount(line.quantity, line.unitPrice), currencyCode, locale }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('invoices.lines.addManual')"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('add')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import { formatMoney, lineAmount } from '@/helpers/money'
|
||||||
|
import { INVOICE_UNITS, type InvoiceFormLine } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const lines = defineModel<InvoiceFormLine[]>({ required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currencyCode: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
add: []
|
||||||
|
remove: [key: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { inputVariant, inputUi, sectionClass } = useInvoiceFieldStyle()
|
||||||
|
|
||||||
|
const fieldUi = { label: 'lg:hidden', container: 'lg:mt-0' }
|
||||||
|
|
||||||
|
const unitItems = computed(() => INVOICE_UNITS.map((unit) => ({ label: t(`invoices.units.${unit}`), value: unit })))
|
||||||
|
|
||||||
|
function toNumber(value: string | number): number {
|
||||||
|
const parsed = Number(String(value).trim())
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="sectionClass">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-default">{{ t('invoices.tasks.title') }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ t('invoices.tasks.hint') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center gap-2 py-3 text-sm text-muted"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-loader-circle"
|
||||||
|
class="size-4 animate-spin"
|
||||||
|
/>
|
||||||
|
{{ t('invoices.tasks.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="goalId === null"
|
||||||
|
class="py-3 text-sm text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.tasks.selectProject') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else-if="tasks.length === 0"
|
||||||
|
class="py-3 text-sm text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.tasks.empty') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col divide-y divide-default rounded-10 bg-elevated/50 max-h-64 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-elevated"
|
||||||
|
>
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="selectedIds.has(task.id)"
|
||||||
|
@update:model-value="emit('toggle', task)"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate text-sm text-default">{{ task.description }}</span>
|
||||||
|
<UBadge
|
||||||
|
v-if="task.complete"
|
||||||
|
:label="t('invoices.tasks.done')"
|
||||||
|
color="success"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span class="text-sm tabular-nums text-muted">{{ formatMoney({ amount: task.amount, currencyCode, locale }) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ALL_TASKS_LIST_ID, TaskIncomeType } from 'taskview-api'
|
||||||
|
import { $tvApi } from '@/plugins/axios'
|
||||||
|
import { logError } from '@/helpers/Helper'
|
||||||
|
import { formatMoney } from '@/helpers/money'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import type { InvoiceTaskOption } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
goalId: number | null
|
||||||
|
currencyCode: string
|
||||||
|
selectedIds: Set<number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [task: InvoiceTaskOption]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { sectionClass } = useInvoiceFieldStyle()
|
||||||
|
const tasks = ref<InvoiceTaskOption[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function loadTasks(goalId: number) {
|
||||||
|
loading.value = true
|
||||||
|
const result = await $tvApi.tasks
|
||||||
|
.fetch({ goalId, componentId: ALL_TASKS_LIST_ID, page: 0, showCompleted: 1, firstNew: 1, unlimited: true })
|
||||||
|
.catch(logError)
|
||||||
|
.finally(() => { loading.value = false })
|
||||||
|
if (!result) return
|
||||||
|
tasks.value = result
|
||||||
|
.filter((task) => task.transactionType === TaskIncomeType && task.amount !== null && task.amount !== '')
|
||||||
|
.map((task) => ({ id: task.id, description: task.description, amount: Number(task.amount), complete: task.complete }))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.goalId,
|
||||||
|
(goalId) => {
|
||||||
|
tasks.value = []
|
||||||
|
if (goalId !== null) loadTasks(goalId)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-start">
|
||||||
|
<div class="flex flex-1 flex-col gap-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.totals.discount')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="String(model.discountValue)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="model.discountValue = toNumber($event)"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.totals.discountType')"
|
||||||
|
class="w-32"
|
||||||
|
>
|
||||||
|
<USelect
|
||||||
|
v-model="model.discountType"
|
||||||
|
:items="discountItems"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.totals.taxRate')"
|
||||||
|
class="w-32"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="String(model.taxRate)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
:disabled="model.taxExempt"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
@update:model-value="model.taxRate = Math.min(toNumber($event), 100)"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<span class="text-xs text-dimmed">%</span>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.totals.taxExempt')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<div class="flex h-8 items-center">
|
||||||
|
<USwitch v-model="model.taxExempt" />
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
v-if="model.taxExempt"
|
||||||
|
:label="t('invoices.totals.taxNote')"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.taxNote"
|
||||||
|
:placeholder="t('invoices.seller.taxNotePlaceholder')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1 rounded-14 bg-elevated px-4 py-3 text-sm lg:w-72">
|
||||||
|
<dt class="text-muted">
|
||||||
|
{{ t('invoices.totals.subtotal') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-right tabular-nums">
|
||||||
|
{{ money(totals.subtotal) }}
|
||||||
|
</dd>
|
||||||
|
<dt class="text-muted">
|
||||||
|
{{ t('invoices.totals.discount') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-right tabular-nums">
|
||||||
|
−{{ money(totals.discount) }}
|
||||||
|
</dd>
|
||||||
|
<dt class="text-muted">
|
||||||
|
{{ t('invoices.totals.tax') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-right tabular-nums">
|
||||||
|
{{ money(totals.tax) }}
|
||||||
|
</dd>
|
||||||
|
<dt class="font-semibold text-default">
|
||||||
|
{{ t('invoices.total') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-right font-semibold tabular-nums text-default">
|
||||||
|
{{ money(totals.total) }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import { computeInvoiceTotals, formatMoney } from '@/helpers/money'
|
||||||
|
import type { InvoiceDiscountType } from 'taskview-api'
|
||||||
|
import type { InvoiceFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<InvoiceFormValue>({ required: true })
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
const { inputVariant, inputUi } = useInvoiceFieldStyle()
|
||||||
|
|
||||||
|
const discountItems = computed(() => [
|
||||||
|
{ label: '%', value: 'percent' as InvoiceDiscountType },
|
||||||
|
{ label: model.value.currencyCode, value: 'amount' as InvoiceDiscountType },
|
||||||
|
])
|
||||||
|
|
||||||
|
const totals = computed(() => computeInvoiceTotals(model.value))
|
||||||
|
|
||||||
|
function toNumber(value: string | number): number {
|
||||||
|
const parsed = Number(String(value).trim())
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(amount: number): string {
|
||||||
|
return formatMoney({ amount, currencyCode: model.value.currencyCode, locale: locale.value })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.party.name')"
|
||||||
|
required
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.name"
|
||||||
|
:placeholder="t('invoices.party.namePlaceholder')"
|
||||||
|
autofocus
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.party.legalName')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.legalName"
|
||||||
|
:placeholder="t('invoices.party.legalNamePlaceholder')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField :label="t('invoices.party.address')">
|
||||||
|
<UTextarea
|
||||||
|
v-model="model.address"
|
||||||
|
:rows="2"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.party.email')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.email"
|
||||||
|
type="email"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.party.phone')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.phone"
|
||||||
|
type="tel"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RequisitesEditor v-model="model.requisites" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends PartyFields">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import RequisitesEditor from './RequisitesEditor.vue'
|
||||||
|
import type { PartyFields } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<T>({ required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi } = useInvoiceFieldStyle()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="sectionClass">
|
||||||
|
<span class="text-sm font-medium text-default">{{ t('invoices.requisites.title') }}</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="item in model"
|
||||||
|
:key="item.key"
|
||||||
|
class="grid grid-cols-[1fr_auto] gap-2 lg:grid-cols-[10rem_1fr_auto]"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="item.label"
|
||||||
|
:placeholder="t('invoices.requisites.label')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<UInput
|
||||||
|
v-model="item.value"
|
||||||
|
:placeholder="t('invoices.requisites.value')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full col-start-1 lg:col-start-auto"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="row-start-1 col-start-2 lg:row-start-auto lg:col-start-auto"
|
||||||
|
@click="remove(item.key)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
|
<UButton
|
||||||
|
v-for="preset in availablePresets"
|
||||||
|
:key="preset"
|
||||||
|
:label="preset"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
@click="add(preset)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('invoices.requisites.add')"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
@click="add('')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import type { BillingRequisite } from 'taskview-api'
|
||||||
|
import { REQUISITE_PRESETS } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<BillingRequisite[]>({ required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi, sectionClass } = useInvoiceFieldStyle()
|
||||||
|
|
||||||
|
const availablePresets = computed(() => {
|
||||||
|
const used = new Set(model.value.map((item) => item.label.trim().toLowerCase()))
|
||||||
|
return REQUISITE_PRESETS.filter((preset) => !used.has(preset.toLowerCase()))
|
||||||
|
})
|
||||||
|
|
||||||
|
function add(label: string) {
|
||||||
|
model.value.push({ key: `req-${Date.now()}-${model.value.length}`, label, value: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(key: string) {
|
||||||
|
model.value = model.value.filter((item) => item.key !== key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<PartyFields v-model="model" />
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.seller.logoUrl')"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="model.logoUrl"
|
||||||
|
placeholder="https://"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="t('invoices.fields.currency')"
|
||||||
|
class="lg:w-40"
|
||||||
|
>
|
||||||
|
<USelectMenu
|
||||||
|
v-model="model.currencyCode"
|
||||||
|
:items="currencyItems"
|
||||||
|
value-key="value"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BankDetailsFields v-model="model.bank" />
|
||||||
|
|
||||||
|
<UFormField :label="t('invoices.seller.taxNote')">
|
||||||
|
<UInput
|
||||||
|
v-model="model.taxNote"
|
||||||
|
:placeholder="t('invoices.seller.taxNotePlaceholder')"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField :label="t('invoices.seller.defaultTerms')">
|
||||||
|
<UTextarea
|
||||||
|
v-model="model.defaultTerms"
|
||||||
|
:placeholder="t('invoices.seller.defaultTermsPlaceholder')"
|
||||||
|
:rows="2"
|
||||||
|
size="xl"
|
||||||
|
:variant="inputVariant"
|
||||||
|
:ui="inputUi"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useInvoiceFieldStyle } from '@/composables/useInvoiceFieldStyle'
|
||||||
|
import PartyFields from './PartyFields.vue'
|
||||||
|
import BankDetailsFields from './BankDetailsFields.vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useCurrenciesStore } from '@/stores/currencies.store'
|
||||||
|
import type { SellerFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const model = defineModel<SellerFormValue>({ required: true })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { inputVariant, inputUi } = useInvoiceFieldStyle()
|
||||||
|
|
||||||
|
const { options: currencyItems } = storeToRefs(useCurrenciesStore())
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
v-model:open="open"
|
||||||
|
:fullscreen="isMobile"
|
||||||
|
:ui="{ content: isMobile ? 'flex flex-col' : 'lg:max-w-2xl max-h-[90vh] flex flex-col' }"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<UCard :ui="{ root: 'flex flex-col flex-1 min-h-0', body: 'flex-1 min-h-0 overflow-y-auto' }">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ isEdit ? t('invoices.seller.editTitle') : t('invoices.seller.create') }}
|
||||||
|
</h3>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<SellerForm v-model="formValue" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span
|
||||||
|
v-if="!canSubmit"
|
||||||
|
class="mr-auto text-xs text-muted"
|
||||||
|
>
|
||||||
|
{{ t('invoices.validation.fillIn') }}: {{ t('invoices.party.name') }}
|
||||||
|
</span>
|
||||||
|
<UButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
@click="open = false"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:label="isEdit ? t('common.save') : t('invoices.seller.create')"
|
||||||
|
variant="soft"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import SellerForm from './SellerForm.vue'
|
||||||
|
import { useTaskView } from '@/composables/useTaskView'
|
||||||
|
import { useSellersStore } from '@/stores/sellers.store'
|
||||||
|
import type { SellerItem } from 'taskview-api'
|
||||||
|
import type { SellerFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
organizationId: number
|
||||||
|
seller?: SellerItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: [seller: SellerItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { isMobile } = useTaskView()
|
||||||
|
const sellersStore = useSellersStore()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.seller)
|
||||||
|
|
||||||
|
function emptyValue(): SellerFormValue {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
legalName: '',
|
||||||
|
address: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
requisites: [],
|
||||||
|
logoUrl: '',
|
||||||
|
currencyCode: 'USD',
|
||||||
|
bank: { bankName: '', accountNumber: '', iban: '', swift: '', correspondentAccount: '' },
|
||||||
|
defaultTerms: '',
|
||||||
|
taxNote: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromSeller(seller: SellerItem): SellerFormValue {
|
||||||
|
return {
|
||||||
|
name: seller.name,
|
||||||
|
legalName: seller.legalName,
|
||||||
|
address: seller.address,
|
||||||
|
email: seller.email,
|
||||||
|
phone: seller.phone,
|
||||||
|
requisites: seller.requisites.map((item) => ({ ...item })),
|
||||||
|
logoUrl: seller.logoUrl,
|
||||||
|
currencyCode: seller.currencyCode,
|
||||||
|
bank: { ...seller.bank },
|
||||||
|
defaultTerms: seller.defaultTerms,
|
||||||
|
taxNote: seller.taxNote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formValue = ref<SellerFormValue>(props.seller ? fromSeller(props.seller) : emptyValue())
|
||||||
|
const canSubmit = computed(() => formValue.value.name.trim().length > 0)
|
||||||
|
|
||||||
|
watch(open, (value) => {
|
||||||
|
if (value) formValue.value = props.seller ? fromSeller(props.seller) : emptyValue()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
const seller = isEdit.value && props.seller
|
||||||
|
? await sellersStore.updateSeller({ sellerId: props.seller.id, value: formValue.value })
|
||||||
|
: await sellersStore.createSeller({ organizationId: props.organizationId, value: formValue.value })
|
||||||
|
if (!seller) {
|
||||||
|
toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.add({ title: t(isEdit.value ? 'invoices.seller.toasts.updated' : 'invoices.seller.toasts.created'), color: 'success' })
|
||||||
|
emit('saved', seller)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -20,13 +20,19 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useOrganizationStore } from '@/stores/organization.store'
|
||||||
|
import { useOrgPermissions } from '@/composables/useOrgPermissions'
|
||||||
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { currentOrg } = storeToRefs(useOrganizationStore())
|
||||||
|
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
|
||||||
|
|
||||||
const links = computed(() => [
|
const links = computed(() => [
|
||||||
{ name: 'analytics', icon: 'i-lucide-bar-chart-3', label: t('userMenu.analytics') },
|
{ name: 'analytics', icon: 'i-lucide-bar-chart-3', label: t('userMenu.analytics') },
|
||||||
|
...(isAdmin.value ? [{ name: 'invoices', icon: 'i-lucide-receipt', label: t('userMenu.invoices') }] : []),
|
||||||
{ name: 'time-reports', icon: 'i-lucide-clock-4', label: t('userMenu.timeReports') },
|
{ name: 'time-reports', icon: 'i-lucide-clock-4', label: t('userMenu.timeReports') },
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Field look shared by every invoices form: xl controls, soft variant (filled
|
||||||
|
// background, no ring) in both themes, 14px radius.
|
||||||
|
export function useInvoiceFieldStyle() {
|
||||||
|
const inputVariant = 'soft' as const
|
||||||
|
const inputUi = { base: 'rounded-14' }
|
||||||
|
|
||||||
|
const dateButtonVariant = 'soft' as const
|
||||||
|
const dateButtonUi = { base: 'rounded-14 justify-start', leadingIcon: 'size-4.5' }
|
||||||
|
|
||||||
|
const sectionClass = 'flex flex-col gap-3 rounded-14 p-3 shadow-sm dark:bg-tv-ui-bg-elevated'
|
||||||
|
|
||||||
|
return { inputVariant, inputUi, dateButtonVariant, dateButtonUi, sectionClass }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { InvoiceMissingRequisite, InvoiceStatus } from 'taskview-api'
|
||||||
|
import type { InvoiceTransitionResult } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const REQUISITE_KEYS: Record<InvoiceMissingRequisite, string> = {
|
||||||
|
'seller.name': 'sellerName',
|
||||||
|
'seller.address': 'sellerAddress',
|
||||||
|
'seller.bank': 'sellerBank',
|
||||||
|
'counterparty.name': 'counterpartyName',
|
||||||
|
'counterparty.address': 'counterpartyAddress',
|
||||||
|
lines: 'lines',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInvoiceTransitionFeedback() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
function report(result: InvoiceTransitionResult, status: InvoiceStatus) {
|
||||||
|
if ('invoice' in result) {
|
||||||
|
toast.add({ title: t(`invoices.toasts.transition.${status}`), color: 'success' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.error === 'missing_requisites') {
|
||||||
|
const items = result.missing.map((key) => t(`invoices.requisiteNames.${REQUISITE_KEYS[key]}`)).join(', ')
|
||||||
|
toast.add({ title: t('invoices.toasts.missingRequisites'), description: items, color: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.add({
|
||||||
|
title: t(result.error === 'invalid_transition' ? 'invoices.toasts.invalidTransition' : 'invoices.toasts.saveFailed'),
|
||||||
|
color: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { report }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { computed, type Ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { InvoiceFormValue } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
export function useInvoiceValidation(value: Ref<InvoiceFormValue>) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const missing = computed(() => {
|
||||||
|
const form = value.value
|
||||||
|
const items: string[] = []
|
||||||
|
if (!form.number.trim()) items.push(t('invoices.fields.number'))
|
||||||
|
if (form.sellerId === null) items.push(t('invoices.seller.title'))
|
||||||
|
if (form.counterpartyId === null) items.push(t('invoices.counterparty.title'))
|
||||||
|
if (form.goalId === null) items.push(t('invoices.fields.project'))
|
||||||
|
if (form.issueDate === null) items.push(t('invoices.fields.issueDate'))
|
||||||
|
if (form.lines.length === 0) items.push(t('invoices.validation.lines'))
|
||||||
|
else if (form.lines.some((line) => !line.description.trim())) items.push(t('invoices.validation.lineDescriptions'))
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => missing.value.length === 0)
|
||||||
|
|
||||||
|
return { missing, canSubmit }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
|
export function httpStatusOf(error: unknown): number | null {
|
||||||
|
return isAxiosError(error) ? (error.response?.status ?? null) : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { InvoiceItem } from 'taskview-api'
|
||||||
|
import type { AddDaysArgs } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
export function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInvoiceOverdue(invoice: Pick<InvoiceItem, 'status' | 'dueDate'>): boolean {
|
||||||
|
return invoice.status === 'issued' && invoice.dueDate !== null && invoice.dueDate < todayIso()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays({ date, days }: AddDaysArgs): string {
|
||||||
|
const [year, month, day] = date.split('-').map(Number)
|
||||||
|
const next = new Date(Date.UTC(year, month - 1, day + days))
|
||||||
|
return next.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ComputeTotalsArgs, FormatMoneyArgs, InvoiceTotals } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
function round2(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney({ amount, currencyCode, locale }: FormatMoneyArgs): string {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode }).format(amount)
|
||||||
|
} catch {
|
||||||
|
return `${amount.toFixed(2)} ${currencyCode}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineAmount(quantity: number, unitPrice: number): number {
|
||||||
|
return round2(quantity * unitPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeInvoiceTotals({ lines, discountType, discountValue, taxRate, taxExempt }: ComputeTotalsArgs): InvoiceTotals {
|
||||||
|
const subtotal = round2(lines.reduce((sum, line) => sum + lineAmount(line.quantity, line.unitPrice), 0))
|
||||||
|
const rawDiscount = discountType === 'percent' ? subtotal * (discountValue / 100) : discountValue
|
||||||
|
const discount = round2(Math.min(Math.max(rawDiscount, 0), subtotal))
|
||||||
|
const taxable = round2(subtotal - discount)
|
||||||
|
const tax = taxExempt ? 0 : round2(taxable * (taxRate / 100))
|
||||||
|
return { subtotal, discount, taxable, tax, total: round2(taxable + tax) }
|
||||||
|
}
|
||||||
@@ -638,6 +638,7 @@ export default {
|
|||||||
organizations: 'Organisationen',
|
organizations: 'Organisationen',
|
||||||
analytics: 'Analytik',
|
analytics: 'Analytik',
|
||||||
timeReports: 'Zeitberichte',
|
timeReports: 'Zeitberichte',
|
||||||
|
invoices: 'Rechnungen',
|
||||||
switchOrganization: 'Organisation wechseln',
|
switchOrganization: 'Organisation wechseln',
|
||||||
logout: 'Abmelden',
|
logout: 'Abmelden',
|
||||||
logoutFailed: 'Abmeldung fehlgeschlagen',
|
logoutFailed: 'Abmeldung fehlgeschlagen',
|
||||||
@@ -1083,6 +1084,191 @@ export default {
|
|||||||
noPermissionHint: 'Bitten Sie einen Projekt-Administrator um die Berechtigung zur Anzeige der Zeiterfassung, um auf Berichte zuzugreifen.',
|
noPermissionHint: 'Bitten Sie einen Projekt-Administrator um die Berechtigung zur Anzeige der Zeiterfassung, um auf Berichte zuzugreifen.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
actions: {
|
||||||
|
issue: 'Ausstellen',
|
||||||
|
markPaid: 'Als bezahlt markieren',
|
||||||
|
unmarkPaid: 'Bezahlt-Markierung entfernen',
|
||||||
|
void: 'Stornieren',
|
||||||
|
reissue: 'Korrigieren',
|
||||||
|
},
|
||||||
|
overdue: 'Überfällig',
|
||||||
|
replaces: 'Ersatz',
|
||||||
|
replacesLink: 'Ersetzt die vorherige Rechnung',
|
||||||
|
dates: {
|
||||||
|
issued: 'Ausgestellt',
|
||||||
|
paid: 'Bezahlt',
|
||||||
|
voided: 'Storniert',
|
||||||
|
},
|
||||||
|
requisiteNames: {
|
||||||
|
sellerName: 'Firmenname',
|
||||||
|
sellerAddress: 'Firmenadresse',
|
||||||
|
sellerBank: 'Kontonummer oder IBAN der Firma',
|
||||||
|
counterpartyName: 'Kundenname',
|
||||||
|
counterpartyAddress: 'Kundenadresse',
|
||||||
|
lines: 'mindestens eine Position',
|
||||||
|
},
|
||||||
|
archive: 'Archivieren',
|
||||||
|
unarchive: 'Aus dem Archiv holen',
|
||||||
|
showArchived: 'Archivierte anzeigen',
|
||||||
|
archivedBadge: 'Archiviert',
|
||||||
|
page: {
|
||||||
|
title: 'Rechnungen',
|
||||||
|
empty: 'Noch keine Rechnungen',
|
||||||
|
emptyHint: 'Ausgestellte und geplante Rechnungen für die Projekte der Organisation erscheinen hier.',
|
||||||
|
noPermission: 'Kein Zugriff auf Rechnungen',
|
||||||
|
noPermissionHint: 'Rechnungen sind für den Eigentümer und die Administratoren der Organisation verfügbar.',
|
||||||
|
},
|
||||||
|
create: 'Rechnung erstellen',
|
||||||
|
editTitle: 'Rechnung bearbeiten',
|
||||||
|
total: 'Gesamt',
|
||||||
|
fields: {
|
||||||
|
number: 'Nummer',
|
||||||
|
reference: 'Vertrag / Bestellung',
|
||||||
|
referencePlaceholder: 'Vertrags- oder Bestellnummer',
|
||||||
|
project: 'Projekt',
|
||||||
|
currency: 'Währung',
|
||||||
|
sellerPlaceholder: 'Firma auswählen',
|
||||||
|
counterpartyPlaceholder: 'Kunde auswählen',
|
||||||
|
issueDate: 'Rechnungsdatum',
|
||||||
|
paymentTerms: 'Zahlungsbedingungen',
|
||||||
|
dueDate: 'Fälligkeitsdatum',
|
||||||
|
periodFrom: 'Zeitraum von',
|
||||||
|
periodTo: 'Zeitraum bis',
|
||||||
|
notes: 'Notizen',
|
||||||
|
notesPlaceholder: 'Kommentar für den Empfänger',
|
||||||
|
terms: 'Bedingungen',
|
||||||
|
termsPlaceholder: 'Zahlungsablauf, Verzugsgebühren, sonstige Bedingungen',
|
||||||
|
},
|
||||||
|
paymentTerms: {
|
||||||
|
on_receipt: 'Sofort fällig',
|
||||||
|
net7: '7 Tage',
|
||||||
|
net14: '14 Tage',
|
||||||
|
net30: '30 Tage',
|
||||||
|
custom: 'Eigenes Datum',
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
service: 'Leistung',
|
||||||
|
hours: 'Stunden',
|
||||||
|
pcs: 'Stück',
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
title: 'Projektaufgaben',
|
||||||
|
hint: 'Angezeigt werden Aufgaben mit Einnahmebetrag',
|
||||||
|
loading: 'Aufgaben werden geladen…',
|
||||||
|
selectProject: 'Zuerst ein Projekt auswählen',
|
||||||
|
empty: 'Keine Aufgaben mit Einnahmebetrag in diesem Projekt',
|
||||||
|
done: 'Erledigt',
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
title: 'Positionen',
|
||||||
|
empty: 'Aufgaben oben markieren oder Position manuell hinzufügen',
|
||||||
|
description: 'Beschreibung',
|
||||||
|
unit: 'Einheit',
|
||||||
|
quantity: 'Menge',
|
||||||
|
price: 'Preis',
|
||||||
|
amount: 'Betrag',
|
||||||
|
addManual: 'Position hinzufügen',
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
subtotal: 'Zwischensumme',
|
||||||
|
discount: 'Rabatt',
|
||||||
|
discountType: 'Art',
|
||||||
|
taxRate: 'Steuer',
|
||||||
|
tax: 'Steuer',
|
||||||
|
taxExempt: 'Steuerfrei',
|
||||||
|
taxNote: 'Begründung',
|
||||||
|
},
|
||||||
|
requisites: {
|
||||||
|
title: 'Registrierungsdaten',
|
||||||
|
label: 'Bezeichnung',
|
||||||
|
value: 'Wert',
|
||||||
|
add: 'Weitere Angabe',
|
||||||
|
},
|
||||||
|
party: {
|
||||||
|
name: 'Name',
|
||||||
|
namePlaceholder: 'Anzeige in Listen',
|
||||||
|
legalName: 'Rechtlicher Name',
|
||||||
|
legalNamePlaceholder: 'Wie im Vertrag',
|
||||||
|
address: 'Adresse',
|
||||||
|
email: 'E-Mail',
|
||||||
|
phone: 'Telefon',
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
title: 'Bankverbindung',
|
||||||
|
bankName: 'Bank',
|
||||||
|
accountNumber: 'Kontonummer',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / BIC',
|
||||||
|
correspondentAccount: 'Korrespondenzkonto',
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
title: 'Meine Firmen',
|
||||||
|
create: 'Firma hinzufügen',
|
||||||
|
editTitle: 'Firma bearbeiten',
|
||||||
|
empty: 'Noch keine Firmen',
|
||||||
|
emptyHint: 'Firma hinzufügen, die Rechnungen ausstellt: Angaben, Bank, Standardbedingungen.',
|
||||||
|
logoUrl: 'Logo (URL)',
|
||||||
|
taxNote: 'Steuerhinweis',
|
||||||
|
taxNotePlaceholder: 'Umsatzsteuerfrei',
|
||||||
|
defaultTerms: 'Standardbedingungen',
|
||||||
|
defaultTermsPlaceholder: 'Werden in jede neue Rechnung übernommen',
|
||||||
|
toasts: {
|
||||||
|
created: 'Firma hinzugefügt',
|
||||||
|
updated: 'Firma gespeichert',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counterparty: {
|
||||||
|
title: 'Kunden',
|
||||||
|
create: 'Kunde hinzufügen',
|
||||||
|
editTitle: 'Kunde bearbeiten',
|
||||||
|
empty: 'Noch keine Kunden',
|
||||||
|
emptyHint: 'Kunden hinzufügen, denen Rechnungen gestellt werden.',
|
||||||
|
kind: 'Typ',
|
||||||
|
kinds: {
|
||||||
|
organization: 'Organisation',
|
||||||
|
person: 'Privatperson',
|
||||||
|
},
|
||||||
|
contactPerson: 'Ansprechpartner',
|
||||||
|
toasts: {
|
||||||
|
created: 'Kunde hinzugefügt',
|
||||||
|
updated: 'Kunde gespeichert',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
download: 'PDF herunterladen',
|
||||||
|
pdfFailed: 'PDF konnte nicht erstellt werden',
|
||||||
|
back: 'Zur Liste',
|
||||||
|
notFound: 'Rechnung nicht gefunden',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
fillIn: 'Ausfüllen',
|
||||||
|
lines: 'mindestens eine Position',
|
||||||
|
lineDescriptions: 'Beschreibung für jede Position',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
draft: 'Entwurf',
|
||||||
|
issued: 'Ausgestellt',
|
||||||
|
paid: 'Bezahlt',
|
||||||
|
void: 'Storniert',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
missingRequisites: 'Vor dem Ausstellen ausfüllen',
|
||||||
|
invalidTransition: 'Dieser Statuswechsel ist nicht erlaubt',
|
||||||
|
notDraft: 'Eine ausgestellte Rechnung kann nicht bearbeitet werden. „Korrigieren“ verwenden',
|
||||||
|
transition: {
|
||||||
|
issued: 'Rechnung ausgestellt',
|
||||||
|
paid: 'Als bezahlt markiert',
|
||||||
|
void: 'Rechnung storniert, Ersatzentwurf erstellt',
|
||||||
|
draft: 'Entwurf',
|
||||||
|
},
|
||||||
|
inUse: 'Löschen nicht möglich: es gibt Rechnungen. Stattdessen archivieren.',
|
||||||
|
duplicateNumber: 'Eine Rechnung mit dieser Nummer existiert bereits',
|
||||||
|
saveFailed: 'Speichern fehlgeschlagen',
|
||||||
|
created: 'Rechnung erstellt',
|
||||||
|
updated: 'Rechnung gespeichert',
|
||||||
|
},
|
||||||
|
},
|
||||||
sprints: {
|
sprints: {
|
||||||
title: 'Sprints',
|
title: 'Sprints',
|
||||||
create: 'Neuer Sprint',
|
create: 'Neuer Sprint',
|
||||||
|
|||||||
@@ -652,6 +652,7 @@ export default {
|
|||||||
organizations: 'Organizations',
|
organizations: 'Organizations',
|
||||||
analytics: 'Analytics',
|
analytics: 'Analytics',
|
||||||
timeReports: 'Time reports',
|
timeReports: 'Time reports',
|
||||||
|
invoices: 'Invoices',
|
||||||
switchOrganization: 'Switch organization',
|
switchOrganization: 'Switch organization',
|
||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
logoutFailed: 'Logout failed',
|
logoutFailed: 'Logout failed',
|
||||||
@@ -1097,6 +1098,191 @@ export default {
|
|||||||
noPermissionHint: 'Ask a project admin for the time-tracking view permission to access reports.',
|
noPermissionHint: 'Ask a project admin for the time-tracking view permission to access reports.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
actions: {
|
||||||
|
issue: 'Issue',
|
||||||
|
markPaid: 'Mark paid',
|
||||||
|
unmarkPaid: 'Unmark paid',
|
||||||
|
void: 'Void',
|
||||||
|
reissue: 'Correct',
|
||||||
|
},
|
||||||
|
overdue: 'Overdue',
|
||||||
|
replaces: 'Replacement',
|
||||||
|
replacesLink: 'Replaces the previous invoice',
|
||||||
|
dates: {
|
||||||
|
issued: 'Issued',
|
||||||
|
paid: 'Paid',
|
||||||
|
voided: 'Voided',
|
||||||
|
},
|
||||||
|
requisiteNames: {
|
||||||
|
sellerName: 'company name',
|
||||||
|
sellerAddress: 'company address',
|
||||||
|
sellerBank: 'company account number or IBAN',
|
||||||
|
counterpartyName: 'client name',
|
||||||
|
counterpartyAddress: 'client address',
|
||||||
|
lines: 'at least one line',
|
||||||
|
},
|
||||||
|
archive: 'Archive',
|
||||||
|
unarchive: 'Restore from archive',
|
||||||
|
showArchived: 'Show archived',
|
||||||
|
archivedBadge: 'Archived',
|
||||||
|
page: {
|
||||||
|
title: 'Invoices',
|
||||||
|
empty: 'No invoices yet',
|
||||||
|
emptyHint: 'Issued and scheduled invoices for the organization’s projects will appear here.',
|
||||||
|
noPermission: 'No access to invoices',
|
||||||
|
noPermissionHint: 'Invoices are available to the organization owner and admins.',
|
||||||
|
},
|
||||||
|
create: 'Create invoice',
|
||||||
|
editTitle: 'Edit invoice',
|
||||||
|
total: 'Total',
|
||||||
|
fields: {
|
||||||
|
number: 'Number',
|
||||||
|
reference: 'Contract / PO',
|
||||||
|
referencePlaceholder: 'Contract or purchase order number',
|
||||||
|
project: 'Project',
|
||||||
|
currency: 'Currency',
|
||||||
|
sellerPlaceholder: 'Select a company',
|
||||||
|
counterpartyPlaceholder: 'Select a client',
|
||||||
|
issueDate: 'Issue date',
|
||||||
|
paymentTerms: 'Payment terms',
|
||||||
|
dueDate: 'Due date',
|
||||||
|
periodFrom: 'Period from',
|
||||||
|
periodTo: 'Period to',
|
||||||
|
notes: 'Notes',
|
||||||
|
notesPlaceholder: 'Comment for the counterparty',
|
||||||
|
terms: 'Terms',
|
||||||
|
termsPlaceholder: 'Payment procedure, late fees, other terms',
|
||||||
|
},
|
||||||
|
paymentTerms: {
|
||||||
|
on_receipt: 'Due on receipt',
|
||||||
|
net7: 'Net 7',
|
||||||
|
net14: 'Net 14',
|
||||||
|
net30: 'Net 30',
|
||||||
|
custom: 'Custom date',
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
service: 'service',
|
||||||
|
hours: 'hours',
|
||||||
|
pcs: 'pieces',
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
title: 'Project tasks',
|
||||||
|
hint: 'Tasks with an income amount are shown',
|
||||||
|
loading: 'Loading tasks…',
|
||||||
|
selectProject: 'Select a project first',
|
||||||
|
empty: 'No tasks with an income amount in this project',
|
||||||
|
done: 'Done',
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
title: 'Lines',
|
||||||
|
empty: 'Tick tasks above or add a line manually',
|
||||||
|
description: 'Description',
|
||||||
|
unit: 'Unit',
|
||||||
|
quantity: 'Qty',
|
||||||
|
price: 'Price',
|
||||||
|
amount: 'Amount',
|
||||||
|
addManual: 'Add line',
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
subtotal: 'Subtotal',
|
||||||
|
discount: 'Discount',
|
||||||
|
discountType: 'Type',
|
||||||
|
taxRate: 'Tax',
|
||||||
|
tax: 'Tax',
|
||||||
|
taxExempt: 'No tax',
|
||||||
|
taxNote: 'Reason',
|
||||||
|
},
|
||||||
|
requisites: {
|
||||||
|
title: 'Registration details',
|
||||||
|
label: 'Label',
|
||||||
|
value: 'Value',
|
||||||
|
add: 'Other detail',
|
||||||
|
},
|
||||||
|
party: {
|
||||||
|
name: 'Name',
|
||||||
|
namePlaceholder: 'Shown in lists',
|
||||||
|
legalName: 'Legal name',
|
||||||
|
legalNamePlaceholder: 'As in the contract',
|
||||||
|
address: 'Address',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Phone',
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
title: 'Bank details',
|
||||||
|
bankName: 'Bank',
|
||||||
|
accountNumber: 'Account number',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / BIC',
|
||||||
|
correspondentAccount: 'Correspondent account',
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
title: 'My companies',
|
||||||
|
create: 'Add company',
|
||||||
|
editTitle: 'Edit company',
|
||||||
|
empty: 'No companies yet',
|
||||||
|
emptyHint: 'Add a company that issues invoices: details, bank, default terms.',
|
||||||
|
logoUrl: 'Logo (URL)',
|
||||||
|
taxNote: 'Tax note',
|
||||||
|
taxNotePlaceholder: 'VAT not applicable',
|
||||||
|
defaultTerms: 'Default terms',
|
||||||
|
defaultTermsPlaceholder: 'Prefilled into every new invoice',
|
||||||
|
toasts: {
|
||||||
|
created: 'Company added',
|
||||||
|
updated: 'Company saved',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counterparty: {
|
||||||
|
title: 'Clients',
|
||||||
|
create: 'Add client',
|
||||||
|
editTitle: 'Edit client',
|
||||||
|
empty: 'No clients yet',
|
||||||
|
emptyHint: 'Add the clients you invoice.',
|
||||||
|
kind: 'Type',
|
||||||
|
kinds: {
|
||||||
|
organization: 'Organization',
|
||||||
|
person: 'Individual',
|
||||||
|
},
|
||||||
|
contactPerson: 'Contact person',
|
||||||
|
toasts: {
|
||||||
|
created: 'Client added',
|
||||||
|
updated: 'Client saved',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
download: 'Download PDF',
|
||||||
|
pdfFailed: 'Could not render the PDF',
|
||||||
|
back: 'Back to list',
|
||||||
|
notFound: 'Invoice not found',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
fillIn: 'Fill in',
|
||||||
|
lines: 'at least one line',
|
||||||
|
lineDescriptions: 'a description for every line',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
draft: 'Draft',
|
||||||
|
issued: 'Issued',
|
||||||
|
paid: 'Paid',
|
||||||
|
void: 'Void',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
missingRequisites: 'Fill in before issuing',
|
||||||
|
invalidTransition: 'This status change is not allowed',
|
||||||
|
notDraft: 'An issued invoice cannot be edited. Use “Correct”',
|
||||||
|
transition: {
|
||||||
|
issued: 'Invoice issued',
|
||||||
|
paid: 'Marked as paid',
|
||||||
|
void: 'Invoice voided, a replacement draft was created',
|
||||||
|
draft: 'Draft',
|
||||||
|
},
|
||||||
|
inUse: 'Cannot delete: there are invoices. Archive it instead.',
|
||||||
|
duplicateNumber: 'An invoice with this number already exists',
|
||||||
|
saveFailed: 'Could not save',
|
||||||
|
created: 'Invoice created',
|
||||||
|
updated: 'Invoice saved',
|
||||||
|
},
|
||||||
|
},
|
||||||
sprints: {
|
sprints: {
|
||||||
title: 'Sprints',
|
title: 'Sprints',
|
||||||
create: 'New sprint',
|
create: 'New sprint',
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ export default {
|
|||||||
organizations: 'Organizaciones',
|
organizations: 'Organizaciones',
|
||||||
analytics: 'Analíticas',
|
analytics: 'Analíticas',
|
||||||
timeReports: 'Informes de tiempo',
|
timeReports: 'Informes de tiempo',
|
||||||
|
invoices: 'Facturas',
|
||||||
switchOrganization: 'Cambiar de organización',
|
switchOrganization: 'Cambiar de organización',
|
||||||
logout: 'Cerrar sesión',
|
logout: 'Cerrar sesión',
|
||||||
logoutFailed: 'Error al cerrar sesión',
|
logoutFailed: 'Error al cerrar sesión',
|
||||||
@@ -1083,6 +1084,191 @@ export default {
|
|||||||
noPermissionHint: 'Pide a un administrador del proyecto el permiso de visualización de registro de tiempo para acceder a los informes.',
|
noPermissionHint: 'Pide a un administrador del proyecto el permiso de visualización de registro de tiempo para acceder a los informes.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
actions: {
|
||||||
|
issue: 'Emitir',
|
||||||
|
markPaid: 'Marcar pagada',
|
||||||
|
unmarkPaid: 'Quitar marca de pago',
|
||||||
|
void: 'Anular',
|
||||||
|
reissue: 'Corregir',
|
||||||
|
},
|
||||||
|
overdue: 'Vencida',
|
||||||
|
replaces: 'Sustitución',
|
||||||
|
replacesLink: 'Sustituye a la factura anterior',
|
||||||
|
dates: {
|
||||||
|
issued: 'Emitida',
|
||||||
|
paid: 'Pagada',
|
||||||
|
voided: 'Anulada',
|
||||||
|
},
|
||||||
|
requisiteNames: {
|
||||||
|
sellerName: 'nombre de la empresa',
|
||||||
|
sellerAddress: 'dirección de la empresa',
|
||||||
|
sellerBank: 'número de cuenta o IBAN de la empresa',
|
||||||
|
counterpartyName: 'nombre del cliente',
|
||||||
|
counterpartyAddress: 'dirección del cliente',
|
||||||
|
lines: 'al menos una línea',
|
||||||
|
},
|
||||||
|
archive: 'Archivar',
|
||||||
|
unarchive: 'Restaurar del archivo',
|
||||||
|
showArchived: 'Mostrar archivados',
|
||||||
|
archivedBadge: 'Archivado',
|
||||||
|
page: {
|
||||||
|
title: 'Facturas',
|
||||||
|
empty: 'Aún no hay facturas',
|
||||||
|
emptyHint: 'Aquí aparecerán las facturas emitidas y programadas de los proyectos de la organización.',
|
||||||
|
noPermission: 'Sin acceso a las facturas',
|
||||||
|
noPermissionHint: 'Las facturas están disponibles para el propietario y los administradores de la organización.',
|
||||||
|
},
|
||||||
|
create: 'Crear factura',
|
||||||
|
editTitle: 'Editar factura',
|
||||||
|
total: 'Total',
|
||||||
|
fields: {
|
||||||
|
number: 'Número',
|
||||||
|
reference: 'Contrato / PO',
|
||||||
|
referencePlaceholder: 'Número de contrato o pedido',
|
||||||
|
project: 'Proyecto',
|
||||||
|
currency: 'Moneda',
|
||||||
|
sellerPlaceholder: 'Selecciona una empresa',
|
||||||
|
counterpartyPlaceholder: 'Selecciona un cliente',
|
||||||
|
issueDate: 'Fecha de emisión',
|
||||||
|
paymentTerms: 'Condiciones de pago',
|
||||||
|
dueDate: 'Fecha de vencimiento',
|
||||||
|
periodFrom: 'Periodo desde',
|
||||||
|
periodTo: 'Periodo hasta',
|
||||||
|
notes: 'Notas',
|
||||||
|
notesPlaceholder: 'Comentario para el cliente',
|
||||||
|
terms: 'Condiciones',
|
||||||
|
termsPlaceholder: 'Forma de pago, recargos por demora, otras condiciones',
|
||||||
|
},
|
||||||
|
paymentTerms: {
|
||||||
|
on_receipt: 'Al recibir',
|
||||||
|
net7: '7 días',
|
||||||
|
net14: '14 días',
|
||||||
|
net30: '30 días',
|
||||||
|
custom: 'Fecha propia',
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
service: 'servicio',
|
||||||
|
hours: 'horas',
|
||||||
|
pcs: 'unidades',
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
title: 'Tareas del proyecto',
|
||||||
|
hint: 'Se muestran las tareas con importe de ingreso',
|
||||||
|
loading: 'Cargando tareas…',
|
||||||
|
selectProject: 'Selecciona primero un proyecto',
|
||||||
|
empty: 'No hay tareas con importe de ingreso en este proyecto',
|
||||||
|
done: 'Hecha',
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
title: 'Líneas',
|
||||||
|
empty: 'Marca tareas arriba o añade una línea manualmente',
|
||||||
|
description: 'Descripción',
|
||||||
|
unit: 'Ud.',
|
||||||
|
quantity: 'Cant.',
|
||||||
|
price: 'Precio',
|
||||||
|
amount: 'Importe',
|
||||||
|
addManual: 'Añadir línea',
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
subtotal: 'Subtotal',
|
||||||
|
discount: 'Descuento',
|
||||||
|
discountType: 'Tipo',
|
||||||
|
taxRate: 'Impuesto',
|
||||||
|
tax: 'Impuesto',
|
||||||
|
taxExempt: 'Sin impuesto',
|
||||||
|
taxNote: 'Motivo',
|
||||||
|
},
|
||||||
|
requisites: {
|
||||||
|
title: 'Datos de registro',
|
||||||
|
label: 'Etiqueta',
|
||||||
|
value: 'Valor',
|
||||||
|
add: 'Otro dato',
|
||||||
|
},
|
||||||
|
party: {
|
||||||
|
name: 'Nombre',
|
||||||
|
namePlaceholder: 'Como se muestra en las listas',
|
||||||
|
legalName: 'Razón social',
|
||||||
|
legalNamePlaceholder: 'Como en el contrato',
|
||||||
|
address: 'Dirección',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Teléfono',
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
title: 'Datos bancarios',
|
||||||
|
bankName: 'Banco',
|
||||||
|
accountNumber: 'Número de cuenta',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / BIC',
|
||||||
|
correspondentAccount: 'Cuenta corresponsal',
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
title: 'Mis empresas',
|
||||||
|
create: 'Añadir empresa',
|
||||||
|
editTitle: 'Editar empresa',
|
||||||
|
empty: 'Aún no hay empresas',
|
||||||
|
emptyHint: 'Añade la empresa que emite las facturas: datos, banco, condiciones por defecto.',
|
||||||
|
logoUrl: 'Logotipo (URL)',
|
||||||
|
taxNote: 'Nota fiscal',
|
||||||
|
taxNotePlaceholder: 'IVA no aplicable',
|
||||||
|
defaultTerms: 'Condiciones por defecto',
|
||||||
|
defaultTermsPlaceholder: 'Se añaden a cada nueva factura',
|
||||||
|
toasts: {
|
||||||
|
created: 'Empresa añadida',
|
||||||
|
updated: 'Empresa guardada',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counterparty: {
|
||||||
|
title: 'Clientes',
|
||||||
|
create: 'Añadir cliente',
|
||||||
|
editTitle: 'Editar cliente',
|
||||||
|
empty: 'Aún no hay clientes',
|
||||||
|
emptyHint: 'Añade los clientes a los que facturas.',
|
||||||
|
kind: 'Tipo',
|
||||||
|
kinds: {
|
||||||
|
organization: 'Organización',
|
||||||
|
person: 'Persona física',
|
||||||
|
},
|
||||||
|
contactPerson: 'Persona de contacto',
|
||||||
|
toasts: {
|
||||||
|
created: 'Cliente añadido',
|
||||||
|
updated: 'Cliente guardado',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
download: 'Descargar PDF',
|
||||||
|
pdfFailed: 'No se pudo generar el PDF',
|
||||||
|
back: 'Volver a la lista',
|
||||||
|
notFound: 'Factura no encontrada',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
fillIn: 'Completa',
|
||||||
|
lines: 'al menos una línea',
|
||||||
|
lineDescriptions: 'descripción en cada línea',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
draft: 'Borrador',
|
||||||
|
issued: 'Emitida',
|
||||||
|
paid: 'Pagada',
|
||||||
|
void: 'Anulada',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
missingRequisites: 'Completa antes de emitir',
|
||||||
|
invalidTransition: 'Este cambio de estado no está permitido',
|
||||||
|
notDraft: 'Una factura emitida no se puede editar. Usa «Corregir»',
|
||||||
|
transition: {
|
||||||
|
issued: 'Factura emitida',
|
||||||
|
paid: 'Marcada como pagada',
|
||||||
|
void: 'Factura anulada, se creó un borrador de sustitución',
|
||||||
|
draft: 'Borrador',
|
||||||
|
},
|
||||||
|
inUse: 'No se puede eliminar: hay facturas. Archívalo en su lugar.',
|
||||||
|
duplicateNumber: 'Ya existe una factura con este número',
|
||||||
|
saveFailed: 'No se pudo guardar',
|
||||||
|
created: 'Factura creada',
|
||||||
|
updated: 'Factura guardada',
|
||||||
|
},
|
||||||
|
},
|
||||||
sprints: {
|
sprints: {
|
||||||
title: 'Sprints',
|
title: 'Sprints',
|
||||||
create: 'Nuevo sprint',
|
create: 'Nuevo sprint',
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ export default {
|
|||||||
organizations: 'Organizações',
|
organizations: 'Organizações',
|
||||||
analytics: 'Análises',
|
analytics: 'Análises',
|
||||||
timeReports: 'Relatórios de tempo',
|
timeReports: 'Relatórios de tempo',
|
||||||
|
invoices: 'Faturas',
|
||||||
switchOrganization: 'Trocar organização',
|
switchOrganization: 'Trocar organização',
|
||||||
logout: 'Sair',
|
logout: 'Sair',
|
||||||
logoutFailed: 'Falha ao sair',
|
logoutFailed: 'Falha ao sair',
|
||||||
@@ -1094,6 +1095,191 @@ export default {
|
|||||||
noPermissionHint: 'Peça a um administrador do projeto permissão de visualização de controle de tempo para acessar relatórios.',
|
noPermissionHint: 'Peça a um administrador do projeto permissão de visualização de controle de tempo para acessar relatórios.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
actions: {
|
||||||
|
issue: 'Emitir',
|
||||||
|
markPaid: 'Marcar como paga',
|
||||||
|
unmarkPaid: 'Remover marca de pagamento',
|
||||||
|
void: 'Cancelar',
|
||||||
|
reissue: 'Corrigir',
|
||||||
|
},
|
||||||
|
overdue: 'Vencida',
|
||||||
|
replaces: 'Substituição',
|
||||||
|
replacesLink: 'Substitui a fatura anterior',
|
||||||
|
dates: {
|
||||||
|
issued: 'Emitida',
|
||||||
|
paid: 'Paga',
|
||||||
|
voided: 'Cancelada',
|
||||||
|
},
|
||||||
|
requisiteNames: {
|
||||||
|
sellerName: 'nome da empresa',
|
||||||
|
sellerAddress: 'endereço da empresa',
|
||||||
|
sellerBank: 'número da conta ou IBAN da empresa',
|
||||||
|
counterpartyName: 'nome do cliente',
|
||||||
|
counterpartyAddress: 'endereço do cliente',
|
||||||
|
lines: 'pelo menos um item',
|
||||||
|
},
|
||||||
|
archive: 'Arquivar',
|
||||||
|
unarchive: 'Restaurar do arquivo',
|
||||||
|
showArchived: 'Mostrar arquivados',
|
||||||
|
archivedBadge: 'Arquivado',
|
||||||
|
page: {
|
||||||
|
title: 'Faturas',
|
||||||
|
empty: 'Ainda não há faturas',
|
||||||
|
emptyHint: 'As faturas emitidas e agendadas dos projetos da organização aparecerão aqui.',
|
||||||
|
noPermission: 'Sem acesso às faturas',
|
||||||
|
noPermissionHint: 'As faturas estão disponíveis para o proprietário e os administradores da organização.',
|
||||||
|
},
|
||||||
|
create: 'Criar fatura',
|
||||||
|
editTitle: 'Editar fatura',
|
||||||
|
total: 'Total',
|
||||||
|
fields: {
|
||||||
|
number: 'Número',
|
||||||
|
reference: 'Contrato / PO',
|
||||||
|
referencePlaceholder: 'Número do contrato ou pedido',
|
||||||
|
project: 'Projeto',
|
||||||
|
currency: 'Moeda',
|
||||||
|
sellerPlaceholder: 'Selecione uma empresa',
|
||||||
|
counterpartyPlaceholder: 'Selecione um cliente',
|
||||||
|
issueDate: 'Data de emissão',
|
||||||
|
paymentTerms: 'Condições de pagamento',
|
||||||
|
dueDate: 'Data de vencimento',
|
||||||
|
periodFrom: 'Período de',
|
||||||
|
periodTo: 'Período até',
|
||||||
|
notes: 'Observações',
|
||||||
|
notesPlaceholder: 'Comentário para o cliente',
|
||||||
|
terms: 'Condições',
|
||||||
|
termsPlaceholder: 'Forma de pagamento, multa por atraso, outras condições',
|
||||||
|
},
|
||||||
|
paymentTerms: {
|
||||||
|
on_receipt: 'No recebimento',
|
||||||
|
net7: '7 dias',
|
||||||
|
net14: '14 dias',
|
||||||
|
net30: '30 dias',
|
||||||
|
custom: 'Data personalizada',
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
service: 'serviço',
|
||||||
|
hours: 'horas',
|
||||||
|
pcs: 'unidades',
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
title: 'Tarefas do projeto',
|
||||||
|
hint: 'São exibidas tarefas com valor de receita',
|
||||||
|
loading: 'Carregando tarefas…',
|
||||||
|
selectProject: 'Selecione um projeto primeiro',
|
||||||
|
empty: 'Nenhuma tarefa com valor de receita neste projeto',
|
||||||
|
done: 'Concluída',
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
title: 'Itens',
|
||||||
|
empty: 'Marque tarefas acima ou adicione um item manualmente',
|
||||||
|
description: 'Descrição',
|
||||||
|
unit: 'Un.',
|
||||||
|
quantity: 'Qtd.',
|
||||||
|
price: 'Preço',
|
||||||
|
amount: 'Valor',
|
||||||
|
addManual: 'Adicionar item',
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
subtotal: 'Subtotal',
|
||||||
|
discount: 'Desconto',
|
||||||
|
discountType: 'Tipo',
|
||||||
|
taxRate: 'Imposto',
|
||||||
|
tax: 'Imposto',
|
||||||
|
taxExempt: 'Sem imposto',
|
||||||
|
taxNote: 'Motivo',
|
||||||
|
},
|
||||||
|
requisites: {
|
||||||
|
title: 'Dados cadastrais',
|
||||||
|
label: 'Rótulo',
|
||||||
|
value: 'Valor',
|
||||||
|
add: 'Outro dado',
|
||||||
|
},
|
||||||
|
party: {
|
||||||
|
name: 'Nome',
|
||||||
|
namePlaceholder: 'Como aparece nas listas',
|
||||||
|
legalName: 'Razão social',
|
||||||
|
legalNamePlaceholder: 'Como no contrato',
|
||||||
|
address: 'Endereço',
|
||||||
|
email: 'E-mail',
|
||||||
|
phone: 'Telefone',
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
title: 'Dados bancários',
|
||||||
|
bankName: 'Banco',
|
||||||
|
accountNumber: 'Número da conta',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / BIC',
|
||||||
|
correspondentAccount: 'Conta correspondente',
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
title: 'Minhas empresas',
|
||||||
|
create: 'Adicionar empresa',
|
||||||
|
editTitle: 'Editar empresa',
|
||||||
|
empty: 'Ainda não há empresas',
|
||||||
|
emptyHint: 'Adicione a empresa que emite as faturas: dados, banco, condições padrão.',
|
||||||
|
logoUrl: 'Logotipo (URL)',
|
||||||
|
taxNote: 'Observação fiscal',
|
||||||
|
taxNotePlaceholder: 'Isento de imposto',
|
||||||
|
defaultTerms: 'Condições padrão',
|
||||||
|
defaultTermsPlaceholder: 'Preenchidas em cada nova fatura',
|
||||||
|
toasts: {
|
||||||
|
created: 'Empresa adicionada',
|
||||||
|
updated: 'Empresa salva',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counterparty: {
|
||||||
|
title: 'Clientes',
|
||||||
|
create: 'Adicionar cliente',
|
||||||
|
editTitle: 'Editar cliente',
|
||||||
|
empty: 'Ainda não há clientes',
|
||||||
|
emptyHint: 'Adicione os clientes para quem você emite faturas.',
|
||||||
|
kind: 'Tipo',
|
||||||
|
kinds: {
|
||||||
|
organization: 'Organização',
|
||||||
|
person: 'Pessoa física',
|
||||||
|
},
|
||||||
|
contactPerson: 'Pessoa de contato',
|
||||||
|
toasts: {
|
||||||
|
created: 'Cliente adicionado',
|
||||||
|
updated: 'Cliente salvo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
download: 'Baixar PDF',
|
||||||
|
pdfFailed: 'Não foi possível gerar o PDF',
|
||||||
|
back: 'Voltar à lista',
|
||||||
|
notFound: 'Fatura não encontrada',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
fillIn: 'Preencha',
|
||||||
|
lines: 'pelo menos um item',
|
||||||
|
lineDescriptions: 'descrição em cada item',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
draft: 'Rascunho',
|
||||||
|
issued: 'Emitida',
|
||||||
|
paid: 'Paga',
|
||||||
|
void: 'Cancelada',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
missingRequisites: 'Preencha antes de emitir',
|
||||||
|
invalidTransition: 'Esta mudança de status não é permitida',
|
||||||
|
notDraft: 'Uma fatura emitida não pode ser editada. Use “Corrigir”',
|
||||||
|
transition: {
|
||||||
|
issued: 'Fatura emitida',
|
||||||
|
paid: 'Marcada como paga',
|
||||||
|
void: 'Fatura cancelada, rascunho de substituição criado',
|
||||||
|
draft: 'Rascunho',
|
||||||
|
},
|
||||||
|
inUse: 'Não é possível excluir: existem faturas. Arquive em vez disso.',
|
||||||
|
duplicateNumber: 'Já existe uma fatura com este número',
|
||||||
|
saveFailed: 'Não foi possível salvar',
|
||||||
|
created: 'Fatura criada',
|
||||||
|
updated: 'Fatura salva',
|
||||||
|
},
|
||||||
|
},
|
||||||
sprints: {
|
sprints: {
|
||||||
title: 'Sprints',
|
title: 'Sprints',
|
||||||
create: 'Nova sprint',
|
create: 'Nova sprint',
|
||||||
|
|||||||
@@ -625,6 +625,7 @@ export default {
|
|||||||
organizations: 'Организации',
|
organizations: 'Организации',
|
||||||
analytics: 'Аналитика',
|
analytics: 'Аналитика',
|
||||||
timeReports: 'Отчёты по времени',
|
timeReports: 'Отчёты по времени',
|
||||||
|
invoices: 'Инвойсы',
|
||||||
switchOrganization: 'Переключить организацию',
|
switchOrganization: 'Переключить организацию',
|
||||||
logout: 'Выйти',
|
logout: 'Выйти',
|
||||||
logoutFailed: 'Не удалось выйти',
|
logoutFailed: 'Не удалось выйти',
|
||||||
@@ -1030,6 +1031,191 @@ export default {
|
|||||||
noPermissionHint: 'Попросите администратора проекта выдать вам право на просмотр учёта времени.',
|
noPermissionHint: 'Попросите администратора проекта выдать вам право на просмотр учёта времени.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
actions: {
|
||||||
|
issue: 'Выставить',
|
||||||
|
markPaid: 'Оплачен',
|
||||||
|
unmarkPaid: 'Снять отметку об оплате',
|
||||||
|
void: 'Аннулировать',
|
||||||
|
reissue: 'Исправить',
|
||||||
|
},
|
||||||
|
overdue: 'Просрочен',
|
||||||
|
replaces: 'Замена',
|
||||||
|
replacesLink: 'Заменяет предыдущий счёт',
|
||||||
|
dates: {
|
||||||
|
issued: 'Выставлен',
|
||||||
|
paid: 'Оплачен',
|
||||||
|
voided: 'Аннулирован',
|
||||||
|
},
|
||||||
|
requisiteNames: {
|
||||||
|
sellerName: 'название компании',
|
||||||
|
sellerAddress: 'адрес компании',
|
||||||
|
sellerBank: 'расчётный счёт или IBAN компании',
|
||||||
|
counterpartyName: 'название клиента',
|
||||||
|
counterpartyAddress: 'адрес клиента',
|
||||||
|
lines: 'хотя бы одна строка',
|
||||||
|
},
|
||||||
|
archive: 'В архив',
|
||||||
|
unarchive: 'Вернуть из архива',
|
||||||
|
showArchived: 'Показать архивные',
|
||||||
|
archivedBadge: 'В архиве',
|
||||||
|
page: {
|
||||||
|
title: 'Инвойсы',
|
||||||
|
empty: 'Инвойсов пока нет',
|
||||||
|
emptyHint: 'Здесь появятся выставленные и запланированные счета по проектам организации.',
|
||||||
|
noPermission: 'Нет доступа к инвойсам',
|
||||||
|
noPermissionHint: 'Инвойсы доступны владельцу и администраторам организации.',
|
||||||
|
},
|
||||||
|
create: 'Создать инвойс',
|
||||||
|
editTitle: 'Редактировать инвойс',
|
||||||
|
total: 'Итого',
|
||||||
|
fields: {
|
||||||
|
number: 'Номер',
|
||||||
|
reference: 'Договор / PO',
|
||||||
|
referencePlaceholder: 'Номер договора или заказа',
|
||||||
|
project: 'Проект',
|
||||||
|
currency: 'Валюта',
|
||||||
|
sellerPlaceholder: 'Выберите компанию',
|
||||||
|
counterpartyPlaceholder: 'Выберите клиента',
|
||||||
|
issueDate: 'Дата выставления',
|
||||||
|
paymentTerms: 'Условия оплаты',
|
||||||
|
dueDate: 'Срок оплаты',
|
||||||
|
periodFrom: 'Период с',
|
||||||
|
periodTo: 'Период по',
|
||||||
|
notes: 'Примечание',
|
||||||
|
notesPlaceholder: 'Комментарий для контрагента',
|
||||||
|
terms: 'Условия',
|
||||||
|
termsPlaceholder: 'Порядок оплаты, пени, прочие условия',
|
||||||
|
},
|
||||||
|
paymentTerms: {
|
||||||
|
on_receipt: 'По получении',
|
||||||
|
net7: '7 дней',
|
||||||
|
net14: '14 дней',
|
||||||
|
net30: '30 дней',
|
||||||
|
custom: 'Своя дата',
|
||||||
|
},
|
||||||
|
units: {
|
||||||
|
service: 'услуга',
|
||||||
|
hours: 'часы',
|
||||||
|
pcs: 'шт.',
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
title: 'Задачи проекта',
|
||||||
|
hint: 'Показаны задачи с указанной суммой дохода',
|
||||||
|
loading: 'Загрузка задач…',
|
||||||
|
selectProject: 'Сначала выберите проект',
|
||||||
|
empty: 'В проекте нет задач с суммой дохода',
|
||||||
|
done: 'Выполнена',
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
title: 'Строки',
|
||||||
|
empty: 'Отметьте задачи выше или добавьте строку вручную',
|
||||||
|
description: 'Описание',
|
||||||
|
unit: 'Ед.',
|
||||||
|
quantity: 'Кол-во',
|
||||||
|
price: 'Цена',
|
||||||
|
amount: 'Сумма',
|
||||||
|
addManual: 'Добавить строку',
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
subtotal: 'Промежуточная сумма',
|
||||||
|
discount: 'Скидка',
|
||||||
|
discountType: 'Тип',
|
||||||
|
taxRate: 'Налог',
|
||||||
|
tax: 'Налог',
|
||||||
|
taxExempt: 'Без налога',
|
||||||
|
taxNote: 'Основание',
|
||||||
|
},
|
||||||
|
requisites: {
|
||||||
|
title: 'Реквизиты',
|
||||||
|
label: 'Название',
|
||||||
|
value: 'Значение',
|
||||||
|
add: 'Другой реквизит',
|
||||||
|
},
|
||||||
|
party: {
|
||||||
|
name: 'Название',
|
||||||
|
namePlaceholder: 'Как показывать в списках',
|
||||||
|
legalName: 'Юридическое название',
|
||||||
|
legalNamePlaceholder: 'Как в договоре',
|
||||||
|
address: 'Адрес',
|
||||||
|
email: 'Email',
|
||||||
|
phone: 'Телефон',
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
title: 'Банковские реквизиты',
|
||||||
|
bankName: 'Банк',
|
||||||
|
accountNumber: 'Расчётный счёт',
|
||||||
|
iban: 'IBAN',
|
||||||
|
swift: 'SWIFT / БИК',
|
||||||
|
correspondentAccount: 'Корр. счёт',
|
||||||
|
},
|
||||||
|
seller: {
|
||||||
|
title: 'Мои компании',
|
||||||
|
create: 'Добавить компанию',
|
||||||
|
editTitle: 'Редактировать компанию',
|
||||||
|
empty: 'Компаний пока нет',
|
||||||
|
emptyHint: 'Добавьте компанию, от имени которой выставляются счета: реквизиты, банк, условия по умолчанию.',
|
||||||
|
logoUrl: 'Логотип (URL)',
|
||||||
|
taxNote: 'Отметка о налоге',
|
||||||
|
taxNotePlaceholder: 'НДС не облагается / VAT not applicable',
|
||||||
|
defaultTerms: 'Условия по умолчанию',
|
||||||
|
defaultTermsPlaceholder: 'Подставляются в каждый новый инвойс',
|
||||||
|
toasts: {
|
||||||
|
created: 'Компания добавлена',
|
||||||
|
updated: 'Компания сохранена',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
counterparty: {
|
||||||
|
title: 'Клиенты',
|
||||||
|
create: 'Добавить клиента',
|
||||||
|
editTitle: 'Редактировать клиента',
|
||||||
|
empty: 'Клиентов пока нет',
|
||||||
|
emptyHint: 'Добавьте клиентов, которым выставляете счета.',
|
||||||
|
kind: 'Тип',
|
||||||
|
kinds: {
|
||||||
|
organization: 'Организация',
|
||||||
|
person: 'Физлицо',
|
||||||
|
},
|
||||||
|
contactPerson: 'Контактное лицо',
|
||||||
|
toasts: {
|
||||||
|
created: 'Клиент добавлен',
|
||||||
|
updated: 'Клиент сохранён',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
download: 'Скачать PDF',
|
||||||
|
pdfFailed: 'Не удалось собрать PDF',
|
||||||
|
back: 'К списку',
|
||||||
|
notFound: 'Инвойс не найден',
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
fillIn: 'Заполните',
|
||||||
|
lines: 'хотя бы одна строка',
|
||||||
|
lineDescriptions: 'описание у каждой строки',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
draft: 'Черновик',
|
||||||
|
issued: 'Выставлен',
|
||||||
|
paid: 'Оплачен',
|
||||||
|
void: 'Аннулирован',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
missingRequisites: 'Заполните перед выставлением',
|
||||||
|
invalidTransition: 'Этот переход статуса недоступен',
|
||||||
|
notDraft: 'Выставленный счёт нельзя редактировать. Используйте «Исправить»',
|
||||||
|
transition: {
|
||||||
|
issued: 'Счёт выставлен',
|
||||||
|
paid: 'Оплата отмечена',
|
||||||
|
void: 'Счёт аннулирован, создан черновик замены',
|
||||||
|
draft: 'Черновик',
|
||||||
|
},
|
||||||
|
inUse: 'Нельзя удалить: есть инвойсы. Отправьте в архив.',
|
||||||
|
duplicateNumber: 'Инвойс с таким номером уже есть',
|
||||||
|
saveFailed: 'Не удалось сохранить',
|
||||||
|
created: 'Инвойс создан',
|
||||||
|
updated: 'Инвойс сохранён',
|
||||||
|
},
|
||||||
|
},
|
||||||
sprints: {
|
sprints: {
|
||||||
title: 'Спринты',
|
title: 'Спринты',
|
||||||
create: 'Новый спринт',
|
create: 'Новый спринт',
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<InvoicesPageShell
|
||||||
|
id="invoice-preview"
|
||||||
|
:title="invoice ? invoice.number : t('invoices.page.title')"
|
||||||
|
:show-tabs="false"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
|
:label="isMobile ? undefined : t('invoices.preview.back')"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:to="{ name: 'invoices' }"
|
||||||
|
/>
|
||||||
|
<InvoiceActions
|
||||||
|
v-if="invoice"
|
||||||
|
:invoice="invoice"
|
||||||
|
:compact="isMobile"
|
||||||
|
:busy="busy"
|
||||||
|
@edit="formOpen = true"
|
||||||
|
@transition="transition"
|
||||||
|
@reissue="reissue"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="invoice"
|
||||||
|
icon="i-lucide-download"
|
||||||
|
:label="isMobile ? undefined : t('invoices.preview.download')"
|
||||||
|
:disabled="!pdfBlob"
|
||||||
|
@click="download"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!invoice"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="loading ? 'i-lucide-loader-circle' : 'i-lucide-file-x'"
|
||||||
|
class="size-12 text-muted"
|
||||||
|
:class="{ 'animate-spin': loading }"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="!loading"
|
||||||
|
class="font-medium text-default"
|
||||||
|
>
|
||||||
|
{{ t('invoices.preview.notFound') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col gap-3 p-2 lg:p-6"
|
||||||
|
>
|
||||||
|
<InvoiceStatusBar :invoice="invoice" />
|
||||||
|
<InvoicePdfViewer
|
||||||
|
:invoice-id="invoice.id"
|
||||||
|
:title="invoice.number"
|
||||||
|
:version="invoice.updatedAt"
|
||||||
|
@loaded="pdfBlob = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InvoiceFormModal
|
||||||
|
v-if="invoice"
|
||||||
|
v-model:open="formOpen"
|
||||||
|
:organization-id="invoice.organizationId"
|
||||||
|
:projects="projectOptions"
|
||||||
|
:invoice="invoice"
|
||||||
|
/>
|
||||||
|
</InvoicesPageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import type { InvoiceStatus } from 'taskview-api'
|
||||||
|
import { useTaskView } from '@/composables/useTaskView'
|
||||||
|
import { useGoalsStore } from '@/stores/goals.store'
|
||||||
|
import { useInvoicesStore } from '@/stores/invoices.store'
|
||||||
|
import { useInvoiceTransitionFeedback } from '@/composables/useInvoiceTransitionFeedback'
|
||||||
|
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
|
||||||
|
import InvoicePdfViewer from '@/components/features/invoices/InvoicePdfViewer.vue'
|
||||||
|
import InvoiceActions from '@/components/features/invoices/InvoiceActions.vue'
|
||||||
|
import InvoiceStatusBar from '@/components/features/invoices/InvoiceStatusBar.vue'
|
||||||
|
import InvoiceFormModal from '@/components/features/invoices/parts/InvoiceFormModal.vue'
|
||||||
|
import type { InvoiceSelectOption } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const invoicesStore = useInvoicesStore()
|
||||||
|
const { goals } = storeToRefs(useGoalsStore())
|
||||||
|
const { isMobile } = useTaskView()
|
||||||
|
const { report } = useInvoiceTransitionFeedback()
|
||||||
|
|
||||||
|
const invoiceId = computed(() => Number(route.params.invoiceId))
|
||||||
|
const invoice = computed(() => invoicesStore.byId(invoiceId.value))
|
||||||
|
const loading = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const pdfBlob = ref<Blob | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
invoiceId,
|
||||||
|
async (id) => {
|
||||||
|
if (invoicesStore.byId(id)) return
|
||||||
|
loading.value = true
|
||||||
|
await invoicesStore.fetchById(id)
|
||||||
|
loading.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function transition(status: InvoiceStatus) {
|
||||||
|
if (!invoice.value || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
const result = await invoicesStore.transition({ invoiceId: invoice.value.id, status })
|
||||||
|
busy.value = false
|
||||||
|
report(result, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reissue() {
|
||||||
|
if (!invoice.value || busy.value) return
|
||||||
|
busy.value = true
|
||||||
|
const result = await invoicesStore.reissue(invoice.value.id)
|
||||||
|
busy.value = false
|
||||||
|
report(result, 'void')
|
||||||
|
if ('invoice' in result) router.push({ name: 'invoice-preview', params: { invoiceId: result.invoice.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!pdfBlob.value || !invoice.value) return
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(pdfBlob.value)
|
||||||
|
link.download = `${invoice.value.number}.pdf`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectOptions = computed<InvoiceSelectOption[]>(() =>
|
||||||
|
goals.value
|
||||||
|
.filter((goal) => goal.organizationId === invoice.value?.organizationId && !goal.archive)
|
||||||
|
.map((goal) => ({ label: goal.name, value: goal.id })),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<InvoicesPageShell
|
||||||
|
id="invoices-counterparties"
|
||||||
|
v-model:include-archived="store.includeArchived"
|
||||||
|
:title="t('invoices.counterparty.title')"
|
||||||
|
:show-tabs="isAdmin"
|
||||||
|
@update:include-archived="reload"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('invoices.counterparty.create')"
|
||||||
|
@click="openCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<InvoicesNoPermission v-if="!isAdmin" />
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="counterparties.length === 0 && !store.loading"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-users"
|
||||||
|
class="size-12 text-muted"
|
||||||
|
/>
|
||||||
|
<p class="font-medium text-default">
|
||||||
|
{{ t('invoices.counterparty.empty') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted max-w-md">
|
||||||
|
{{ t('invoices.counterparty.emptyHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col gap-2 p-2 lg:p-6"
|
||||||
|
>
|
||||||
|
<PartyListItem
|
||||||
|
v-for="item in counterparties"
|
||||||
|
:key="item.id"
|
||||||
|
:icon="item.kind === 'person' ? 'i-lucide-user' : 'i-lucide-building'"
|
||||||
|
:title="item.name"
|
||||||
|
:subtitle="[item.legalName, item.email].filter(Boolean).join(' · ')"
|
||||||
|
:archived="item.archived"
|
||||||
|
@edit="openEdit(item)"
|
||||||
|
@archive="store.setArchived({ id: item.id, archived: $event })"
|
||||||
|
@delete="remove(item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<CounterpartyFormModal
|
||||||
|
v-if="orgId !== null"
|
||||||
|
v-model:open="formOpen"
|
||||||
|
:organization-id="orgId"
|
||||||
|
:counterparty="editing"
|
||||||
|
/>
|
||||||
|
</InvoicesPageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import type { CounterpartyItem } from 'taskview-api'
|
||||||
|
import { useOrganizationStore } from '@/stores/organization.store'
|
||||||
|
import { useCounterpartiesStore } from '@/stores/counterparties.store'
|
||||||
|
import { useOrgPermissions } from '@/composables/useOrgPermissions'
|
||||||
|
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
|
||||||
|
import InvoicesNoPermission from '@/components/features/invoices/InvoicesNoPermission.vue'
|
||||||
|
import PartyListItem from '@/components/features/invoices/PartyListItem.vue'
|
||||||
|
import CounterpartyFormModal from '@/components/features/invoices/parts/CounterpartyFormModal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { currentOrg } = storeToRefs(useOrganizationStore())
|
||||||
|
const store = useCounterpartiesStore()
|
||||||
|
const { counterparties } = storeToRefs(store)
|
||||||
|
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
|
||||||
|
|
||||||
|
const orgId = computed(() => currentOrg.value?.id ?? null)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
if (orgId.value !== null && isAdmin.value) store.fetch(orgId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([orgId, isAdmin], reload, { immediate: true })
|
||||||
|
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const editing = ref<CounterpartyItem | null>(null)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: CounterpartyItem) {
|
||||||
|
editing.value = item
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
const result = await store.deleteCounterparty(id)
|
||||||
|
if (result === 'in_use') toast.add({ title: t('invoices.toasts.inUse'), color: 'warning' })
|
||||||
|
else if (result === 'failed') toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<InvoicesPageShell
|
||||||
|
id="invoices-sellers"
|
||||||
|
v-model:include-archived="store.includeArchived"
|
||||||
|
:title="t('invoices.seller.title')"
|
||||||
|
:show-tabs="isAdmin"
|
||||||
|
@update:include-archived="reload"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('invoices.seller.create')"
|
||||||
|
@click="openCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<InvoicesNoPermission v-if="!isAdmin" />
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="sellers.length === 0 && !store.loading"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-16 px-4 text-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-building-2"
|
||||||
|
class="size-12 text-muted"
|
||||||
|
/>
|
||||||
|
<p class="font-medium text-default">
|
||||||
|
{{ t('invoices.seller.empty') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted max-w-md">
|
||||||
|
{{ t('invoices.seller.emptyHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col gap-2 p-2 lg:p-6"
|
||||||
|
>
|
||||||
|
<PartyListItem
|
||||||
|
v-for="item in sellers"
|
||||||
|
:key="item.id"
|
||||||
|
icon="i-lucide-building-2"
|
||||||
|
:title="item.name"
|
||||||
|
:subtitle="[item.legalName, item.currencyCode].filter(Boolean).join(' · ')"
|
||||||
|
:archived="item.archived"
|
||||||
|
@edit="openEdit(item)"
|
||||||
|
@archive="store.setArchived({ id: item.id, archived: $event })"
|
||||||
|
@delete="remove(item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<SellerFormModal
|
||||||
|
v-if="orgId !== null"
|
||||||
|
v-model:open="formOpen"
|
||||||
|
:organization-id="orgId"
|
||||||
|
:seller="editing"
|
||||||
|
/>
|
||||||
|
</InvoicesPageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import type { SellerItem } from 'taskview-api'
|
||||||
|
import { useOrganizationStore } from '@/stores/organization.store'
|
||||||
|
import { useSellersStore } from '@/stores/sellers.store'
|
||||||
|
import { useOrgPermissions } from '@/composables/useOrgPermissions'
|
||||||
|
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
|
||||||
|
import InvoicesNoPermission from '@/components/features/invoices/InvoicesNoPermission.vue'
|
||||||
|
import PartyListItem from '@/components/features/invoices/PartyListItem.vue'
|
||||||
|
import SellerFormModal from '@/components/features/invoices/parts/SellerFormModal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const { currentOrg } = storeToRefs(useOrganizationStore())
|
||||||
|
const store = useSellersStore()
|
||||||
|
const { sellers } = storeToRefs(store)
|
||||||
|
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
|
||||||
|
|
||||||
|
const orgId = computed(() => currentOrg.value?.id ?? null)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
if (orgId.value !== null && isAdmin.value) store.fetch(orgId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([orgId, isAdmin], reload, { immediate: true })
|
||||||
|
|
||||||
|
const formOpen = ref(false)
|
||||||
|
const editing = ref<SellerItem | null>(null)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: SellerItem) {
|
||||||
|
editing.value = item
|
||||||
|
formOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
const result = await store.deleteSeller(id)
|
||||||
|
if (result === 'in_use') toast.add({ title: t('invoices.toasts.inUse'), color: 'warning' })
|
||||||
|
else if (result === 'failed') toast.add({ title: t('invoices.toasts.saveFailed'), color: 'error' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<InvoicesPageShell
|
||||||
|
id="invoices"
|
||||||
|
v-model:include-archived="invoicesStore.includeArchived"
|
||||||
|
:title="t('invoices.page.title')"
|
||||||
|
:show-tabs="isAdmin"
|
||||||
|
@update:include-archived="reload"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('invoices.create')"
|
||||||
|
@click="formOpen = true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<InvoicesNoPermission v-if="!isAdmin" />
|
||||||
|
<template v-else>
|
||||||
|
<InvoicesEmptyState v-if="invoices.length === 0 && !invoicesStore.loading" />
|
||||||
|
<InvoicesList
|
||||||
|
v-else
|
||||||
|
:invoices="invoices"
|
||||||
|
@open="openPreview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<InvoiceFormModal
|
||||||
|
v-if="orgId !== null"
|
||||||
|
v-model:open="formOpen"
|
||||||
|
:organization-id="orgId"
|
||||||
|
:projects="projectOptions"
|
||||||
|
@saved="openPreview"
|
||||||
|
/>
|
||||||
|
</InvoicesPageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import type { InvoiceItem } from 'taskview-api'
|
||||||
|
import { useOrganizationStore } from '@/stores/organization.store'
|
||||||
|
import { useGoalsStore } from '@/stores/goals.store'
|
||||||
|
import { useInvoicesStore } from '@/stores/invoices.store'
|
||||||
|
import { useOrgPermissions } from '@/composables/useOrgPermissions'
|
||||||
|
import InvoicesPageShell from '@/components/features/invoices/InvoicesPageShell.vue'
|
||||||
|
import InvoicesEmptyState from '@/components/features/invoices/InvoicesEmptyState.vue'
|
||||||
|
import InvoicesNoPermission from '@/components/features/invoices/InvoicesNoPermission.vue'
|
||||||
|
import InvoicesList from '@/components/features/invoices/InvoicesList.vue'
|
||||||
|
import InvoiceFormModal from '@/components/features/invoices/parts/InvoiceFormModal.vue'
|
||||||
|
import type { InvoiceSelectOption } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentOrg } = storeToRefs(useOrganizationStore())
|
||||||
|
const { goals } = storeToRefs(useGoalsStore())
|
||||||
|
const invoicesStore = useInvoicesStore()
|
||||||
|
const { invoices } = storeToRefs(invoicesStore)
|
||||||
|
const { isAdmin } = useOrgPermissions(() => currentOrg.value)
|
||||||
|
|
||||||
|
const orgId = computed(() => currentOrg.value?.id ?? null)
|
||||||
|
|
||||||
|
const projectOptions = computed<InvoiceSelectOption[]>(() =>
|
||||||
|
goals.value
|
||||||
|
.filter((goal) => goal.organizationId === orgId.value && !goal.archive)
|
||||||
|
.map((goal) => ({ label: goal.name, value: goal.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formOpen = ref(false)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
if (orgId.value !== null && isAdmin.value) invoicesStore.fetch(orgId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([orgId, isAdmin], reload, { immediate: true })
|
||||||
|
|
||||||
|
function openPreview(invoice: InvoiceItem) {
|
||||||
|
router.push({ name: 'invoice-preview', params: { invoiceId: invoice.id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { CounterpartyItem } from 'taskview-api'
|
||||||
|
import { $tvApi } from '@/plugins/axios'
|
||||||
|
import { logError } from '@/helpers/Helper'
|
||||||
|
import { httpStatusOf } from '@/helpers/billingErrors'
|
||||||
|
import type {
|
||||||
|
ArchiveArgs,
|
||||||
|
BillingDeleteResult,
|
||||||
|
CounterpartiesStoreState,
|
||||||
|
CreateCounterpartyArgs,
|
||||||
|
UpdateCounterpartyArgs,
|
||||||
|
} from '@/types/invoices.types'
|
||||||
|
|
||||||
|
export const useCounterpartiesStore = defineStore('counterparties', {
|
||||||
|
state: (): CounterpartiesStoreState => ({
|
||||||
|
counterparties: [],
|
||||||
|
loading: false,
|
||||||
|
includeArchived: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
active: (state) => state.counterparties.filter((item) => !item.archived),
|
||||||
|
byId: (state) => (counterpartyId: number) => state.counterparties.find((item) => item.id === counterpartyId) ?? null,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetch(organizationId: number) {
|
||||||
|
this.loading = true
|
||||||
|
const result = await $tvApi.billing
|
||||||
|
.fetchCounterparties({ organizationId, includeArchived: this.includeArchived })
|
||||||
|
.catch(logError)
|
||||||
|
.finally(() => { this.loading = false })
|
||||||
|
if (result) this.counterparties = result
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCounterparty({ organizationId, value }: CreateCounterpartyArgs): Promise<CounterpartyItem | null> {
|
||||||
|
const counterparty = await $tvApi.billing.createCounterparty({ organizationId, ...value }).catch(logError)
|
||||||
|
if (!counterparty) return null
|
||||||
|
this.counterparties.push(counterparty)
|
||||||
|
return counterparty
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCounterparty({ counterpartyId, value }: UpdateCounterpartyArgs): Promise<CounterpartyItem | null> {
|
||||||
|
const counterparty = await $tvApi.billing.updateCounterparty({ id: counterpartyId, data: value }).catch(logError)
|
||||||
|
if (!counterparty) return null
|
||||||
|
this.replace(counterparty)
|
||||||
|
return counterparty
|
||||||
|
},
|
||||||
|
|
||||||
|
async setArchived({ id, archived }: ArchiveArgs): Promise<CounterpartyItem | null> {
|
||||||
|
const counterparty = await $tvApi.billing.archiveCounterparty({ id, archived }).catch(logError)
|
||||||
|
if (!counterparty) return null
|
||||||
|
if (!archived || this.includeArchived) this.replace(counterparty)
|
||||||
|
else this.counterparties = this.counterparties.filter((item) => item.id !== id)
|
||||||
|
return counterparty
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCounterparty(counterpartyId: number): Promise<BillingDeleteResult> {
|
||||||
|
try {
|
||||||
|
await $tvApi.billing.deleteCounterparty(counterpartyId)
|
||||||
|
} catch (error) {
|
||||||
|
return httpStatusOf(error) === 409 ? 'in_use' : 'failed'
|
||||||
|
}
|
||||||
|
this.counterparties = this.counterparties.filter((item) => item.id !== counterpartyId)
|
||||||
|
return 'deleted'
|
||||||
|
},
|
||||||
|
|
||||||
|
replace(counterparty: CounterpartyItem) {
|
||||||
|
const index = this.counterparties.findIndex((item) => item.id === counterparty.id)
|
||||||
|
if (index >= 0) this.counterparties[index] = counterparty
|
||||||
|
else this.counterparties.push(counterparty)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { $tvApi } from '@/plugins/axios'
|
||||||
|
import { logError } from '@/helpers/Helper'
|
||||||
|
import type { CurrenciesStoreState } from '@/types/invoices.types'
|
||||||
|
|
||||||
|
export const useCurrenciesStore = defineStore('currencies', {
|
||||||
|
state: (): CurrenciesStoreState => ({
|
||||||
|
currencies: [],
|
||||||
|
loaded: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
options: (state) =>
|
||||||
|
state.currencies.map((currency) => ({ label: `${currency.code} ${currency.symbol}`, value: currency.code })),
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetch() {
|
||||||
|
if (this.loaded) return
|
||||||
|
const result = await $tvApi.billing.fetchCurrencies().catch(logError)
|
||||||
|
if (!result) return
|
||||||
|
this.currencies = result.map((currency) => ({
|
||||||
|
code: currency.code.trim(),
|
||||||
|
symbol: currency.symbol,
|
||||||
|
decimalDigits: currency.decimalDigits,
|
||||||
|
}))
|
||||||
|
this.loaded = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import type { InvoiceArgCreate, InvoiceItem, InvoicePdfLang, InvoiceTransitionError } from 'taskview-api'
|
||||||
|
import { $tvApi } from '@/plugins/axios'
|
||||||
|
import { logError } from '@/helpers/Helper'
|
||||||
|
import { httpStatusOf } from '@/helpers/billingErrors'
|
||||||
|
import type {
|
||||||
|
CreateInvoiceArgs,
|
||||||
|
InvoiceFormValue,
|
||||||
|
InvoiceSaveResult,
|
||||||
|
InvoiceTransitionResult,
|
||||||
|
InvoicesStoreState,
|
||||||
|
TransitionInvoiceArgs,
|
||||||
|
UpdateInvoiceArgs,
|
||||||
|
} from '@/types/invoices.types'
|
||||||
|
|
||||||
|
export const useInvoicesStore = defineStore('invoices', {
|
||||||
|
state: (): InvoicesStoreState => ({
|
||||||
|
invoices: [],
|
||||||
|
loading: false,
|
||||||
|
includeArchived: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
byId: (state) => (invoiceId: number) => state.invoices.find((invoice) => invoice.id === invoiceId) ?? null,
|
||||||
|
|
||||||
|
lastCounterpartyForGoal: (state) => (goalId: number) =>
|
||||||
|
state.invoices.find((invoice) => invoice.goalId === goalId)?.counterpartyId ?? null,
|
||||||
|
|
||||||
|
suggestedNumber: (state) => () =>
|
||||||
|
`INV-${new Date().getFullYear()}-${String(state.invoices.length + 1).padStart(4, '0')}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetch(organizationId: number) {
|
||||||
|
this.loading = true
|
||||||
|
const result = await $tvApi.invoices
|
||||||
|
.fetch({ organizationId, includeArchived: this.includeArchived })
|
||||||
|
.catch(logError)
|
||||||
|
.finally(() => { this.loading = false })
|
||||||
|
if (result) this.invoices = result
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchById(invoiceId: number): Promise<InvoiceItem | null> {
|
||||||
|
const invoice = await $tvApi.invoices.fetchById(invoiceId).catch(logError)
|
||||||
|
if (!invoice) return null
|
||||||
|
this.replace(invoice)
|
||||||
|
return invoice
|
||||||
|
},
|
||||||
|
|
||||||
|
toPayload(value: InvoiceFormValue): Omit<InvoiceArgCreate, 'organizationId'> {
|
||||||
|
return {
|
||||||
|
goalId: value.goalId,
|
||||||
|
sellerId: value.sellerId as number,
|
||||||
|
counterpartyId: value.counterpartyId as number,
|
||||||
|
number: value.number.trim(),
|
||||||
|
reference: value.reference.trim(),
|
||||||
|
currencyCode: value.currencyCode,
|
||||||
|
issueDate: value.issueDate as string,
|
||||||
|
paymentTerms: value.paymentTerms,
|
||||||
|
dueDate: value.dueDate,
|
||||||
|
periodFrom: value.periodFrom,
|
||||||
|
periodTo: value.periodTo,
|
||||||
|
discountType: value.discountType,
|
||||||
|
discountValue: value.discountValue,
|
||||||
|
taxRate: value.taxRate,
|
||||||
|
taxExempt: value.taxExempt,
|
||||||
|
taxNote: value.taxNote.trim(),
|
||||||
|
notes: value.notes.trim(),
|
||||||
|
terms: value.terms.trim(),
|
||||||
|
lines: value.lines.map((line) => ({
|
||||||
|
taskId: line.taskId,
|
||||||
|
description: line.description.trim(),
|
||||||
|
unit: line.unit,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createInvoice({ organizationId, value }: CreateInvoiceArgs): Promise<InvoiceSaveResult> {
|
||||||
|
try {
|
||||||
|
const invoice = await $tvApi.invoices.create({ organizationId, ...this.toPayload(value) })
|
||||||
|
this.invoices.unshift(invoice)
|
||||||
|
return { invoice }
|
||||||
|
} catch (error) {
|
||||||
|
logError(error)
|
||||||
|
return { error: httpStatusOf(error) === 409 ? 'duplicate_number' : 'failed' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateInvoice({ invoiceId, value }: UpdateInvoiceArgs): Promise<InvoiceSaveResult> {
|
||||||
|
try {
|
||||||
|
const invoice = await $tvApi.invoices.update({ id: invoiceId, data: this.toPayload(value) })
|
||||||
|
this.replace(invoice)
|
||||||
|
return { invoice }
|
||||||
|
} catch (error) {
|
||||||
|
logError(error)
|
||||||
|
const status = httpStatusOf(error)
|
||||||
|
const body = isAxiosError(error) ? (error.response?.data as { error?: string } | undefined) : undefined
|
||||||
|
if (status === 409 && body?.error === 'not_draft') return { error: 'not_draft' }
|
||||||
|
return { error: status === 409 ? 'duplicate_number' : 'failed' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async transition({ invoiceId, status }: TransitionInvoiceArgs): Promise<InvoiceTransitionResult> {
|
||||||
|
try {
|
||||||
|
const invoice = await $tvApi.invoices.setStatus({ id: invoiceId, status })
|
||||||
|
this.replace(invoice)
|
||||||
|
return { invoice }
|
||||||
|
} catch (error) {
|
||||||
|
return this.toTransitionError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async reissue(invoiceId: number): Promise<InvoiceTransitionResult> {
|
||||||
|
try {
|
||||||
|
const copy = await $tvApi.invoices.reissue(invoiceId)
|
||||||
|
this.replace(copy)
|
||||||
|
await this.fetchById(invoiceId)
|
||||||
|
return { invoice: copy }
|
||||||
|
} catch (error) {
|
||||||
|
return this.toTransitionError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchPdf(invoiceId: number, lang: InvoicePdfLang): Promise<Blob | null> {
|
||||||
|
return $tvApi.invoices.fetchPdf({ id: invoiceId, lang }).catch((error) => {
|
||||||
|
logError(error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
toTransitionError(error: unknown): InvoiceTransitionResult {
|
||||||
|
logError(error)
|
||||||
|
const body = isAxiosError(error) ? (error.response?.data as InvoiceTransitionError | undefined) : undefined
|
||||||
|
return body && typeof body === 'object' && 'error' in body ? body : { error: 'failed' }
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteInvoice(invoiceId: number): Promise<boolean> {
|
||||||
|
const result = await $tvApi.invoices.delete(invoiceId).catch(logError)
|
||||||
|
if (!result) return false
|
||||||
|
this.invoices = this.invoices.filter((invoice) => invoice.id !== invoiceId)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
replace(invoice: InvoiceItem) {
|
||||||
|
const index = this.invoices.findIndex((item) => item.id === invoice.id)
|
||||||
|
if (index >= 0) this.invoices[index] = invoice
|
||||||
|
else this.invoices.unshift(invoice)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { SellerItem } from 'taskview-api'
|
||||||
|
import { $tvApi } from '@/plugins/axios'
|
||||||
|
import { logError } from '@/helpers/Helper'
|
||||||
|
import { httpStatusOf } from '@/helpers/billingErrors'
|
||||||
|
import type {
|
||||||
|
ArchiveArgs,
|
||||||
|
BillingDeleteResult,
|
||||||
|
CreateSellerArgs,
|
||||||
|
SellersStoreState,
|
||||||
|
UpdateSellerArgs,
|
||||||
|
} from '@/types/invoices.types'
|
||||||
|
|
||||||
|
export const useSellersStore = defineStore('sellers', {
|
||||||
|
state: (): SellersStoreState => ({
|
||||||
|
sellers: [],
|
||||||
|
loading: false,
|
||||||
|
includeArchived: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
active: (state) => state.sellers.filter((seller) => !seller.archived),
|
||||||
|
byId: (state) => (sellerId: number) => state.sellers.find((seller) => seller.id === sellerId) ?? null,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetch(organizationId: number) {
|
||||||
|
this.loading = true
|
||||||
|
const result = await $tvApi.billing
|
||||||
|
.fetchSellers({ organizationId, includeArchived: this.includeArchived })
|
||||||
|
.catch(logError)
|
||||||
|
.finally(() => { this.loading = false })
|
||||||
|
if (result) this.sellers = result
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSeller({ organizationId, value }: CreateSellerArgs): Promise<SellerItem | null> {
|
||||||
|
const seller = await $tvApi.billing.createSeller({ organizationId, ...value }).catch(logError)
|
||||||
|
if (!seller) return null
|
||||||
|
this.sellers.push(seller)
|
||||||
|
return seller
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSeller({ sellerId, value }: UpdateSellerArgs): Promise<SellerItem | null> {
|
||||||
|
const seller = await $tvApi.billing.updateSeller({ id: sellerId, data: value }).catch(logError)
|
||||||
|
if (!seller) return null
|
||||||
|
this.replace(seller)
|
||||||
|
return seller
|
||||||
|
},
|
||||||
|
|
||||||
|
async setArchived({ id, archived }: ArchiveArgs): Promise<SellerItem | null> {
|
||||||
|
const seller = await $tvApi.billing.archiveSeller({ id, archived }).catch(logError)
|
||||||
|
if (!seller) return null
|
||||||
|
if (!archived || this.includeArchived) this.replace(seller)
|
||||||
|
else this.sellers = this.sellers.filter((item) => item.id !== id)
|
||||||
|
return seller
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSeller(sellerId: number): Promise<BillingDeleteResult> {
|
||||||
|
try {
|
||||||
|
await $tvApi.billing.deleteSeller(sellerId)
|
||||||
|
} catch (error) {
|
||||||
|
return httpStatusOf(error) === 409 ? 'in_use' : 'failed'
|
||||||
|
}
|
||||||
|
this.sellers = this.sellers.filter((seller) => seller.id !== sellerId)
|
||||||
|
return 'deleted'
|
||||||
|
},
|
||||||
|
|
||||||
|
replace(seller: SellerItem) {
|
||||||
|
const index = this.sellers.findIndex((item) => item.id === seller.id)
|
||||||
|
if (index >= 0) this.sellers[index] = seller
|
||||||
|
else this.sellers.push(seller)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type {
|
||||||
|
CounterpartyArgCreate,
|
||||||
|
CounterpartyItem,
|
||||||
|
InvoiceDiscountType,
|
||||||
|
InvoiceItem,
|
||||||
|
InvoiceLineItem,
|
||||||
|
InvoicePaymentTerms,
|
||||||
|
InvoiceStatus,
|
||||||
|
InvoiceTransitionError,
|
||||||
|
InvoiceUnit,
|
||||||
|
SellerArgCreate,
|
||||||
|
SellerItem,
|
||||||
|
} from 'taskview-api'
|
||||||
|
|
||||||
|
export type SellerFormValue = Omit<SellerArgCreate, 'organizationId'>
|
||||||
|
|
||||||
|
export type PartyFields = Pick<SellerFormValue, 'name' | 'legalName' | 'address' | 'email' | 'phone' | 'requisites'>
|
||||||
|
export type CounterpartyFormValue = Omit<CounterpartyArgCreate, 'organizationId'>
|
||||||
|
|
||||||
|
export type InvoiceFormLine = Omit<InvoiceLineItem, 'id'> & {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceFormValue = {
|
||||||
|
number: string
|
||||||
|
reference: string
|
||||||
|
goalId: number | null
|
||||||
|
sellerId: number | null
|
||||||
|
counterpartyId: number | null
|
||||||
|
currencyCode: string
|
||||||
|
issueDate: string | null
|
||||||
|
paymentTerms: InvoicePaymentTerms
|
||||||
|
dueDate: string | null
|
||||||
|
periodFrom: string | null
|
||||||
|
periodTo: string | null
|
||||||
|
lines: InvoiceFormLine[]
|
||||||
|
discountType: InvoiceDiscountType
|
||||||
|
discountValue: number
|
||||||
|
taxRate: number
|
||||||
|
taxExempt: boolean
|
||||||
|
taxNote: string
|
||||||
|
notes: string
|
||||||
|
terms: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceTotals = {
|
||||||
|
subtotal: number
|
||||||
|
discount: number
|
||||||
|
taxable: number
|
||||||
|
tax: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComputeTotalsArgs = {
|
||||||
|
lines: Pick<InvoiceLineItem, 'quantity' | 'unitPrice'>[]
|
||||||
|
discountType: InvoiceDiscountType
|
||||||
|
discountValue: number
|
||||||
|
taxRate: number
|
||||||
|
taxExempt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceTaskOption = {
|
||||||
|
id: number
|
||||||
|
description: string
|
||||||
|
amount: number
|
||||||
|
complete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceSelectOption = {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormatMoneyArgs = {
|
||||||
|
amount: number
|
||||||
|
currencyCode: string
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddDaysArgs = {
|
||||||
|
date: string
|
||||||
|
days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoicesStoreState = {
|
||||||
|
invoices: InvoiceItem[]
|
||||||
|
loading: boolean
|
||||||
|
includeArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SellersStoreState = {
|
||||||
|
sellers: SellerItem[]
|
||||||
|
loading: boolean
|
||||||
|
includeArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CounterpartiesStoreState = {
|
||||||
|
counterparties: CounterpartyItem[]
|
||||||
|
loading: boolean
|
||||||
|
includeArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrenciesStoreState = {
|
||||||
|
currencies: { code: string, symbol: string, decimalDigits: number }[]
|
||||||
|
loaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateInvoiceArgs = {
|
||||||
|
organizationId: number
|
||||||
|
value: InvoiceFormValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateInvoiceArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
value: InvoiceFormValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateSellerArgs = {
|
||||||
|
organizationId: number
|
||||||
|
value: SellerFormValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSellerArgs = {
|
||||||
|
sellerId: number
|
||||||
|
value: SellerFormValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateCounterpartyArgs = {
|
||||||
|
organizationId: number
|
||||||
|
value: CounterpartyFormValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCounterpartyArgs = {
|
||||||
|
counterpartyId: number
|
||||||
|
value: CounterpartyFormValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArchiveArgs = {
|
||||||
|
id: number
|
||||||
|
archived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingDeleteResult = 'deleted' | 'in_use' | 'failed'
|
||||||
|
export type InvoiceSaveResult = { invoice: InvoiceItem } | { error: 'duplicate_number' | 'not_draft' | 'failed' }
|
||||||
|
export type InvoiceTransitionResult = { invoice: InvoiceItem } | InvoiceTransitionError | { error: 'failed' }
|
||||||
|
|
||||||
|
export type TransitionInvoiceArgs = {
|
||||||
|
invoiceId: number
|
||||||
|
status: InvoiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INVOICE_UNITS: InvoiceUnit[] = ['service', 'hours', 'pcs']
|
||||||
|
|
||||||
|
export const PAYMENT_TERMS_DAYS: Record<InvoicePaymentTerms, number | null> = {
|
||||||
|
on_receipt: 0,
|
||||||
|
net7: 7,
|
||||||
|
net14: 14,
|
||||||
|
net30: 30,
|
||||||
|
custom: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REQUISITE_PRESETS = ['ИНН', 'КПП', 'ОГРН', 'VAT ID', 'EIN', 'Reg. No.']
|
||||||
Reference in New Issue
Block a user