create-protobase 0.23.13 → 0.23.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask-preset.js +25 -0
- package/dist/create-project.js +7 -6
- package/dist/index.js +19 -9
- package/dist/next-steps.js +8 -2
- package/dist/presets.js +17 -0
- package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
- package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
- package/dist/templates/erp/.env.example +14 -0
- package/dist/templates/erp/AGENTS.md +24 -0
- package/dist/templates/erp/auth/auth.ts +68 -0
- package/dist/templates/erp/components/orders-per-day.tsx +21 -0
- package/dist/templates/erp/config/bottom-bar.ts +9 -0
- package/dist/templates/erp/config/categories/data.ts +20 -0
- package/dist/templates/erp/config/categories/ui.ts +11 -0
- package/dist/templates/erp/config/companies/data.ts +23 -0
- package/dist/templates/erp/config/companies/ui.ts +22 -0
- package/dist/templates/erp/config/company_tags/data.ts +16 -0
- package/dist/templates/erp/config/company_tags/ui.ts +7 -0
- package/dist/templates/erp/config/countries/data.ts +13 -0
- package/dist/templates/erp/config/countries/ui.ts +6 -0
- package/dist/templates/erp/config/currencies/data.ts +14 -0
- package/dist/templates/erp/config/currencies/ui.ts +6 -0
- package/dist/templates/erp/config/emp_master/data.ts +19 -0
- package/dist/templates/erp/config/emp_master/ui.ts +7 -0
- package/dist/templates/erp/config/index.ts +42 -0
- package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
- package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
- package/dist/templates/erp/config/invoices/data.ts +26 -0
- package/dist/templates/erp/config/invoices/ui.ts +38 -0
- package/dist/templates/erp/config/jobs.test.ts +23 -0
- package/dist/templates/erp/config/jobs.ts +29 -0
- package/dist/templates/erp/config/locations/data.ts +18 -0
- package/dist/templates/erp/config/locations/ui.ts +6 -0
- package/dist/templates/erp/config/order_lines/data.ts +20 -0
- package/dist/templates/erp/config/order_lines/ui.ts +7 -0
- package/dist/templates/erp/config/orders/data.ts +27 -0
- package/dist/templates/erp/config/orders/ui.ts +41 -0
- package/dist/templates/erp/config/organizations/data.ts +17 -0
- package/dist/templates/erp/config/organizations/ui.ts +6 -0
- package/dist/templates/erp/config/overview/page.tsx +80 -0
- package/dist/templates/erp/config/pages.test.ts +14 -0
- package/dist/templates/erp/config/people/data.ts +21 -0
- package/dist/templates/erp/config/people/ui.ts +6 -0
- package/dist/templates/erp/config/products/data.ts +25 -0
- package/dist/templates/erp/config/products/ui.ts +6 -0
- package/dist/templates/erp/config/roles.test.ts +56 -0
- package/dist/templates/erp/config/roles.ts +54 -0
- package/dist/templates/erp/config/shell.test.ts +24 -0
- package/dist/templates/erp/config/stock_levels/data.ts +16 -0
- package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
- package/dist/templates/erp/config/stock_moves/data.ts +22 -0
- package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
- package/dist/templates/erp/config/tags/data.ts +17 -0
- package/dist/templates/erp/config/tags/ui.ts +6 -0
- package/dist/templates/erp/config/user-menu.ts +9 -0
- package/dist/templates/erp/config/users/data.ts +19 -0
- package/dist/templates/erp/config/users/ui.ts +9 -0
- package/dist/templates/erp/config/warehouses/data.ts +19 -0
- package/dist/templates/erp/config/warehouses/ui.ts +6 -0
- package/dist/templates/erp/data.ts +3 -0
- package/dist/templates/erp/db/connection.ts +7 -0
- package/dist/templates/erp/db/drop.ts +9 -0
- package/dist/templates/erp/db/kysely.ts +13 -0
- package/dist/templates/erp/db/migrations/001_core.sql +48 -0
- package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
- package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
- package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
- package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
- package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
- package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
- package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
- package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
- package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
- package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
- package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
- package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
- package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
- package/dist/templates/erp/package.json +38 -0
- package/dist/templates/erp/protobase.config.ts +22 -0
- package/dist/templates/erp/protobase.ui.tsx +14 -0
- package/dist/templates/erp/seed/auth-organizations.ts +11 -0
- package/dist/templates/erp/seed/base-organization.test.ts +17 -0
- package/dist/templates/erp/seed/base-organization.ts +10 -0
- package/dist/templates/erp/seed/base.ts +22 -0
- package/dist/templates/erp/seed/builders/categories.ts +18 -0
- package/dist/templates/erp/seed/builders/company.ts +61 -0
- package/dist/templates/erp/seed/builders/invoice.ts +31 -0
- package/dist/templates/erp/seed/builders/order.ts +66 -0
- package/dist/templates/erp/seed/builders/product.ts +46 -0
- package/dist/templates/erp/seed/calendar.ts +60 -0
- package/dist/templates/erp/seed/cli.ts +54 -0
- package/dist/templates/erp/seed/copy.ts +22 -0
- package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
- package/dist/templates/erp/seed/data/layout.ts +19 -0
- package/dist/templates/erp/seed/data/names.ts +44 -0
- package/dist/templates/erp/seed/data/reference.ts +53 -0
- package/dist/templates/erp/seed/format.ts +24 -0
- package/dist/templates/erp/seed/maintenance.ts +63 -0
- package/dist/templates/erp/seed/rng.ts +36 -0
- package/dist/templates/erp/seed/scales.ts +9 -0
- package/dist/templates/erp/seed/steps/catalog.ts +34 -0
- package/dist/templates/erp/seed/steps/crm.ts +62 -0
- package/dist/templates/erp/seed/steps/hr.ts +26 -0
- package/dist/templates/erp/seed/steps/ledger.ts +88 -0
- package/dist/templates/erp/seed/steps/reference.ts +24 -0
- package/dist/templates/erp/seed/steps/sales.ts +58 -0
- package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
- package/dist/templates/erp/seed/world.ts +86 -0
- package/dist/templates/erp/vitest.config.ts +4 -0
- package/dist/templates/real-estate/.env.example +9 -0
- package/dist/templates/real-estate/AGENTS.md +24 -0
- package/dist/templates/real-estate/_.gitignore +3 -0
- package/dist/templates/real-estate/_bunfig.toml +4 -0
- package/dist/templates/real-estate/auth/auth.ts +36 -0
- package/dist/templates/real-estate/config/amenities/data.ts +12 -0
- package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
- package/dist/templates/real-estate/config/arrears/data.ts +19 -0
- package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
- package/dist/templates/real-estate/config/deposits/data.ts +21 -0
- package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
- package/dist/templates/real-estate/config/index.ts +41 -0
- package/dist/templates/real-estate/config/inspections/data.ts +22 -0
- package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
- package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
- package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
- package/dist/templates/real-estate/config/leases/data.ts +29 -0
- package/dist/templates/real-estate/config/leases/ui.ts +33 -0
- package/dist/templates/real-estate/config/organizations/data.ts +17 -0
- package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
- package/dist/templates/real-estate/config/overview/page.tsx +57 -0
- package/dist/templates/real-estate/config/owners/data.ts +22 -0
- package/dist/templates/real-estate/config/owners/ui.ts +17 -0
- package/dist/templates/real-estate/config/pages.test.ts +49 -0
- package/dist/templates/real-estate/config/payments/data.ts +20 -0
- package/dist/templates/real-estate/config/payments/ui.ts +14 -0
- package/dist/templates/real-estate/config/properties/data.ts +26 -0
- package/dist/templates/real-estate/config/properties/ui.ts +21 -0
- package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
- package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
- package/dist/templates/real-estate/config/roles.test.ts +49 -0
- package/dist/templates/real-estate/config/roles.ts +45 -0
- package/dist/templates/real-estate/config/shell.test.ts +18 -0
- package/dist/templates/real-estate/config/tenants/data.ts +23 -0
- package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
- package/dist/templates/real-estate/config/tickets/data.ts +33 -0
- package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
- package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
- package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
- package/dist/templates/real-estate/config/units/data.ts +24 -0
- package/dist/templates/real-estate/config/units/ui.ts +41 -0
- package/dist/templates/real-estate/config/user-menu.ts +9 -0
- package/dist/templates/real-estate/config/users/data.ts +19 -0
- package/dist/templates/real-estate/config/users/ui.ts +11 -0
- package/dist/templates/real-estate/config/valuations/data.ts +19 -0
- package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
- package/dist/templates/real-estate/config/vendors/data.ts +21 -0
- package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
- package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
- package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
- package/dist/templates/real-estate/db/connection.ts +7 -0
- package/dist/templates/real-estate/db/drop.ts +9 -0
- package/dist/templates/real-estate/db/kysely.ts +13 -0
- package/dist/templates/real-estate/db/migrate.ts +30 -0
- package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
- package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
- package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
- package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
- package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
- package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
- package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
- package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
- package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
- package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
- package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
- package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
- package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
- package/dist/templates/real-estate/node-types.d.ts +1 -0
- package/dist/templates/real-estate/package.json +38 -0
- package/dist/templates/real-estate/protobase.config.ts +5 -0
- package/dist/templates/real-estate/protobase.ui.tsx +16 -0
- package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
- package/dist/templates/real-estate/seed/base-organization.ts +10 -0
- package/dist/templates/real-estate/seed/base.ts +18 -0
- package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
- package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
- package/dist/templates/real-estate/seed/builders/property.ts +98 -0
- package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
- package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
- package/dist/templates/real-estate/seed/calendar.ts +77 -0
- package/dist/templates/real-estate/seed/cli.ts +50 -0
- package/dist/templates/real-estate/seed/copy.ts +22 -0
- package/dist/templates/real-estate/seed/data/names.ts +31 -0
- package/dist/templates/real-estate/seed/data/reference.ts +63 -0
- package/dist/templates/real-estate/seed/format.ts +24 -0
- package/dist/templates/real-estate/seed/iban.ts +15 -0
- package/dist/templates/real-estate/seed/maintenance.ts +63 -0
- package/dist/templates/real-estate/seed/rng.ts +36 -0
- package/dist/templates/real-estate/seed/scales.ts +9 -0
- package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
- package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
- package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
- package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
- package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
- package/dist/templates/real-estate/seed/world.ts +92 -0
- package/dist/templates/real-estate/tsconfig.json +16 -0
- package/dist/templates/real-estate/vitest.config.ts +4 -0
- package/dist/templates/scratch/_.gitignore +3 -0
- package/dist/templates/scratch/_bunfig.toml +4 -0
- package/dist/templates/scratch/db/migrate.ts +30 -0
- package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
- package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
- package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
- package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
- package/dist/templates/scratch/node-types.d.ts +1 -0
- package/dist/{template → templates/scratch}/package.json +5 -5
- package/dist/templates/scratch/tsconfig.json +16 -0
- package/package.json +2 -2
- /package/dist/{template → templates/erp}/_.gitignore +0 -0
- /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
- /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
- /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
- /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
- /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
- /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
- /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
- /package/dist/{template → templates/erp}/tsconfig.json +0 -0
- /package/dist/{template → templates/scratch}/.env.example +0 -0
- /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
- /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
- /package/dist/{template → templates/scratch}/config/index.ts +0 -0
- /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
- /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
- /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
- /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
- /package/dist/{template → templates/scratch}/vitest.config.ts +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
//#region src/ask-preset.ts
|
|
3
|
+
/** The preset for an answer: its number in `names` or its name, the first one when the answer is empty. */
|
|
4
|
+
const presetForAnswer = (names, answer) => {
|
|
5
|
+
const text = answer.trim();
|
|
6
|
+
if (text === "") return names[0];
|
|
7
|
+
const index = Number(text);
|
|
8
|
+
if (Number.isInteger(index) && index >= 1 && index <= names.length) return names[index - 1];
|
|
9
|
+
return names.find((name) => name === text);
|
|
10
|
+
};
|
|
11
|
+
const menu = (names) => `Which preset do you want to start from?\n${names.map((name, i) => ` ${i + 1}. ${name}${i === 0 ? " (default)" : ""}\n`).join("")}`;
|
|
12
|
+
/** Asks in the terminal which of `names` to start from, again until the answer is one of them. */
|
|
13
|
+
const askPreset = async (names) => {
|
|
14
|
+
const prompt = createInterface({
|
|
15
|
+
input: process.stdin,
|
|
16
|
+
output: process.stdout
|
|
17
|
+
});
|
|
18
|
+
process.stdout.write(menu(names));
|
|
19
|
+
let picked;
|
|
20
|
+
while (!picked) picked = presetForAnswer(names, await prompt.question(`Preset [1-${names.length}]: `));
|
|
21
|
+
prompt.close();
|
|
22
|
+
return picked;
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { askPreset, presetForAnswer };
|
package/dist/create-project.js
CHANGED
|
@@ -2,19 +2,20 @@ import path from "node:path";
|
|
|
2
2
|
import { cpSync, existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
//#region src/create-project.ts
|
|
4
4
|
/**
|
|
5
|
-
* Writes a new project to `targetDir` from `templateDir`, which the build writes from
|
|
6
|
-
*
|
|
5
|
+
* Writes a new project to `targetDir` from `templateDir`, which the build writes from a preset, with its package.json
|
|
6
|
+
* named after the folder, and returns that package.json. The folder may exist only when it is empty.
|
|
7
7
|
*/
|
|
8
8
|
const createProject = (templateDir, targetDir) => {
|
|
9
9
|
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) throw new Error(`${targetDir} is not empty`);
|
|
10
10
|
cpSync(templateDir, targetDir, { recursive: true });
|
|
11
11
|
for (const file of readdirSync(targetDir).filter((file) => file.startsWith("_"))) renameSync(path.join(targetDir, file), path.join(targetDir, file.slice(1)));
|
|
12
12
|
const packageFile = path.join(targetDir, "package.json");
|
|
13
|
-
const manifest =
|
|
14
|
-
|
|
15
|
-
...manifest,
|
|
13
|
+
const manifest = {
|
|
14
|
+
...JSON.parse(readFileSync(packageFile, "utf8")),
|
|
16
15
|
name: path.basename(targetDir)
|
|
17
|
-
}
|
|
16
|
+
};
|
|
17
|
+
writeFileSync(packageFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
18
|
+
return manifest;
|
|
18
19
|
};
|
|
19
20
|
//#endregion
|
|
20
21
|
export { createProject };
|
package/dist/index.js
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
|
+
import { askPreset } from "./ask-preset.js";
|
|
1
2
|
import { createProject } from "./create-project.js";
|
|
2
3
|
import { nextSteps } from "./next-steps.js";
|
|
4
|
+
import { checkPreset, defaultPreset, presetNames } from "./presets.js";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
import { parseArgs } from "node:util";
|
|
5
7
|
//#region src/index.ts
|
|
6
|
-
const
|
|
7
|
-
const usage = "usage: npm create protobase <dir>";
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const templatesDir = path.join(import.meta.dirname, "templates");
|
|
9
|
+
const usage = "usage: npm create protobase <dir> [-- --preset <name>]";
|
|
10
|
+
const pickPreset = async (flag) => {
|
|
11
|
+
const names = presetNames(templatesDir);
|
|
12
|
+
if (flag !== void 0) return checkPreset(names, flag);
|
|
13
|
+
if (process.stdin.isTTY && process.stdout.isTTY) return askPreset(names);
|
|
14
|
+
return defaultPreset;
|
|
15
|
+
};
|
|
16
|
+
const create = async (argv) => {
|
|
17
|
+
const { values, positionals } = parseArgs({
|
|
10
18
|
args: argv.slice(2),
|
|
11
|
-
allowPositionals: true
|
|
19
|
+
allowPositionals: true,
|
|
20
|
+
options: { preset: { type: "string" } }
|
|
12
21
|
});
|
|
13
22
|
const [dir, ...extra] = positionals;
|
|
14
23
|
if (!dir || extra.length > 0) throw new Error(usage);
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
const preset = await pickPreset(values.preset);
|
|
25
|
+
const { scripts } = createProject(path.join(templatesDir, preset), path.resolve(dir));
|
|
26
|
+
process.stdout.write(`Created a Protobase project in ${dir} from the ${preset} preset. Next:\n\n${nextSteps(dir, scripts ?? {})}`);
|
|
17
27
|
};
|
|
18
|
-
const run = (argv) => {
|
|
28
|
+
const run = async (argv) => {
|
|
19
29
|
try {
|
|
20
|
-
create(argv);
|
|
30
|
+
await create(argv);
|
|
21
31
|
} catch (error) {
|
|
22
32
|
process.stderr.write(`error: ${error.message}\n`);
|
|
23
33
|
process.exitCode = 1;
|
package/dist/next-steps.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
//#region src/next-steps.ts
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* What to run in a new project, from `cwd`'s point of view, until its admin app serves. `scripts` are those of the
|
|
4
|
+
* project's package.json: a preset with a base seed loads it, and one with sample data offers it, before the first user,
|
|
5
|
+
* who then owns the organizations the seeds wrote.
|
|
6
|
+
*/
|
|
7
|
+
const nextSteps = (dir, scripts) => [
|
|
4
8
|
`cd ${dir}`,
|
|
5
9
|
"bun install",
|
|
6
10
|
"cp .env.example .env # then set DATABASE_URL and BETTER_AUTH_SECRET",
|
|
7
11
|
"bun run db:migrate",
|
|
12
|
+
..."db:seed:base" in scripts ? ["bun run db:seed:base"] : [],
|
|
13
|
+
..."db:seed" in scripts ? ["bun run db:seed --scale small # optional: sample data, replacing the app tables"] : [],
|
|
8
14
|
"bun run protobase users create you@example.com --generate-password",
|
|
9
15
|
"bun run dev # http://localhost:5173"
|
|
10
16
|
].map((line) => ` ${line}\n`).join("");
|
package/dist/presets.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
//#region src/presets.ts
|
|
3
|
+
/** The preset a new project starts from when nobody picks one. */
|
|
4
|
+
const defaultPreset = "scratch";
|
|
5
|
+
/** The presets the build wrote to `templatesDir`, one folder each, the default first and the others by name. */
|
|
6
|
+
const presetNames = (templatesDir) => {
|
|
7
|
+
const names = readdirSync(templatesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
8
|
+
if (!names.includes("scratch")) throw new Error(`no ${defaultPreset} preset in ${templatesDir}`);
|
|
9
|
+
return [defaultPreset, ...names.filter((name) => name !== defaultPreset)];
|
|
10
|
+
};
|
|
11
|
+
/** `name` when it is one of `names`, else an error that lists them. */
|
|
12
|
+
const checkPreset = (names, name) => {
|
|
13
|
+
if (!names.includes(name)) throw new Error(`unknown preset ${name}: pick one of ${names.join(", ")}`);
|
|
14
|
+
return name;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { checkPreset, defaultPreset, presetNames };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import pg from 'pg'
|
|
2
|
+
import postgres from 'postgres'
|
|
3
|
+
import { jsonArrayFrom } from 'kysely/helpers/postgres'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import * as config from './resources'
|
|
6
|
+
export default { config, options: { found: [typeof pg.Pool, typeof postgres, typeof jsonArrayFrom, typeof z.object] } }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The ERP's own settings. The nearest .env wins, so this file, not the repository's, is the one `protobase dev`,
|
|
2
|
+
# `protobase serve` and the db:* scripts read here. The Postgres container is the repository's (`bun run db:up`).
|
|
3
|
+
DATABASE_URL=postgres://protobase:protobase@localhost:55432/protobase
|
|
4
|
+
|
|
5
|
+
# Admin store (Better Auth users, sessions, signing keys): the `auth` schema of DATABASE_URL, created by `db:migrate`.
|
|
6
|
+
# Public URL of the API, for cookies and as the issuer of the login tokens (use the https tunnel URL when tunnelling).
|
|
7
|
+
BETTER_AUTH_URL=http://localhost:5173
|
|
8
|
+
# Signs cookies and encrypts the token keys. Generate your own: openssl rand -base64 32
|
|
9
|
+
BETTER_AUTH_SECRET=change-me-generate-with-openssl-rand-base64-32
|
|
10
|
+
# Extra comma separated origins allowed to call the auth endpoints (a dev tunnel needs its exact host here, such as https://my-tunnel.trycloudflare.com).
|
|
11
|
+
# TRUSTED_ORIGINS=
|
|
12
|
+
# Mail for password reset and emailed sign-in and two-factor codes: the SMTP server (smtp:// with STARTTLS, or smtps://) and the sender. Without them those are off.
|
|
13
|
+
# PROTOBASE_SMTP_URL=smtp://user:password@localhost:1025
|
|
14
|
+
# PROTOBASE_MAIL_FROM=noreply@example.com
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Working on this app
|
|
2
|
+
|
|
3
|
+
A Protobase app: each resource's data config in `config/<name>/data.ts` and its screens in `config/<name>/ui.ts`, all
|
|
4
|
+
exported from `config/index.ts`, over a Postgres database. Reference: https://docs.protobase.net.
|
|
5
|
+
|
|
6
|
+
## Database changes
|
|
7
|
+
|
|
8
|
+
- Every schema change is a new migration file in `db/migrations/`, numbered after the last one (`009_returns.sql`).
|
|
9
|
+
Never edit or remove a migration that exists: it has already run on the live database.
|
|
10
|
+
- Expand only, where possible: add tables, columns and indexes, and keep what the running app still reads. Rename or
|
|
11
|
+
drop only when asked to, once nothing uses it.
|
|
12
|
+
- A new table gets `organization_id integer not null references core.organizations (id)` and `.tenant(...)`, like the
|
|
13
|
+
others: people work in one organization at a time, and see only its rows (`auth/auth.ts`).
|
|
14
|
+
|
|
15
|
+
## Never touch
|
|
16
|
+
|
|
17
|
+
- `auth/` and the `auth` schema: sign-in and accounts.
|
|
18
|
+
- `.github/workflows/`.
|
|
19
|
+
|
|
20
|
+
## Scripts
|
|
21
|
+
|
|
22
|
+
The platform runs `db:migrate` (the auth schema's migrations included), `typecheck` and `test`, with their settings in
|
|
23
|
+
the environment: keep them working. Run `typecheck` and `test` before you finish. `db:seed:base` loads the reference data and organization 1 (in the auth schema too),
|
|
24
|
+
and `db:seed` the sample data, replacing everything in the app's tables.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { PostgresDialect } from 'kysely'
|
|
2
|
+
import pg from 'pg'
|
|
3
|
+
import { betterAuthAuthenticator, createAuth, handleDroppedConnections } from '@protobase/server'
|
|
4
|
+
import { databaseUrl } from '../db/connection'
|
|
5
|
+
|
|
6
|
+
const secret = process.env.BETTER_AUTH_SECRET
|
|
7
|
+
if (!secret) throw new Error('BETTER_AUTH_SECRET is not set; generate one with `openssl rand -base64 32` and put it in .env')
|
|
8
|
+
|
|
9
|
+
// One pool across hot reloads of this module
|
|
10
|
+
const store = globalThis as { __erpAdminPool?: pg.Pool }
|
|
11
|
+
store.__erpAdminPool ??= handleDroppedConnections(new pg.Pool({ connectionString: databaseUrl, max: 5 }))
|
|
12
|
+
|
|
13
|
+
/** The admin store (Better Auth's users, sessions and signing keys) lives in this schema, next to the ERP's own schemas. */
|
|
14
|
+
export const authSchema = 'auth'
|
|
15
|
+
|
|
16
|
+
const pool = store.__erpAdminPool
|
|
17
|
+
|
|
18
|
+
// The ERP's records name their organization by core.organizations' integer id, so a new organization takes the next
|
|
19
|
+
// value of that table's sequence, and gets its row there with the ERP's own settings once it is made.
|
|
20
|
+
const nextOrganizationId = async () => {
|
|
21
|
+
const { rows } = await pool.query<{ id: string }>("select nextval(pg_get_serial_sequence('core.organizations', 'id'))::text as id")
|
|
22
|
+
return rows[0]!.id
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const addOrganizationRow = async ({ organization }: { organization: { id: string; name: string; slug: string } }) => {
|
|
26
|
+
await pool.query(
|
|
27
|
+
"insert into core.organizations (id, name, slug, country_code, currency_code) values ($1, $2, $3, 'NL', 'EUR') on conflict (id) do nothing",
|
|
28
|
+
[Number(organization.id), organization.name, organization.slug],
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Orders name their owner by core.users' integer id, so a new user takes the next value of that table's sequence; their
|
|
33
|
+
// row there comes with their first membership (db/migrations/019_auth_users.sql).
|
|
34
|
+
const nextUserId = async () => {
|
|
35
|
+
const { rows } = await pool.query<{ id: string }>("select nextval(pg_get_serial_sequence('core.users', 'id'))::text as id")
|
|
36
|
+
return rows[0]!.id
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const auth = createAuth({
|
|
40
|
+
database: { dialect: new PostgresDialect({ pool }), type: 'postgres', schemaName: authSchema, transaction: true },
|
|
41
|
+
baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:5173',
|
|
42
|
+
secret,
|
|
43
|
+
// `admin` is the superuser, in every organization; the others are held per organization (or globally, by a few).
|
|
44
|
+
roles: {
|
|
45
|
+
admin: 'Superuser',
|
|
46
|
+
auditor: 'Auditor',
|
|
47
|
+
manager: { label: 'Manager', grants: ['sales', 'accountant', 'warehouse', 'editor'] },
|
|
48
|
+
sales: 'Sales',
|
|
49
|
+
accountant: 'Accountant',
|
|
50
|
+
warehouse: 'Warehouse',
|
|
51
|
+
editor: 'Editor',
|
|
52
|
+
integration: 'Integration',
|
|
53
|
+
},
|
|
54
|
+
generateUserId: nextUserId,
|
|
55
|
+
organizations: {
|
|
56
|
+
generateId: nextOrganizationId,
|
|
57
|
+
onCreated: addOrganizationRow,
|
|
58
|
+
hooks: {
|
|
59
|
+
afterUpdateOrganization: async ({ organization }) => {
|
|
60
|
+
if (organization) await pool.query('update core.organizations set name = $2, slug = $3 where id = $1', [Number(organization.id), organization.name, organization.slug])
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
trustedOrigins: process.env.TRUSTED_ORIGINS?.split(',').filter(Boolean),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** The organization comes from the signed-in person's session: the one they work in. */
|
|
68
|
+
export const authenticate = betterAuthAuthenticator({ auth })
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useSeries } from '@protobase/ui'
|
|
2
|
+
|
|
3
|
+
/** Bars for orders per day; the custom component behind `<OrdersPerDay>` in config/overview/page.tsx. */
|
|
4
|
+
export const OrdersPerDay = ({ days }: { days: 7 | 30 | 90 }) => {
|
|
5
|
+
const series = useSeries('orders', { field: 'created_at', range: `${days}d`, granularity: 'day' })
|
|
6
|
+
if (series.isPending) return <p className="text-muted-foreground">Loading</p>
|
|
7
|
+
if (series.error) return <p className="text-danger-text">Could not load the orders</p>
|
|
8
|
+
const points = series.data.points
|
|
9
|
+
const highest = Math.max(1, ...points.map((point) => point.count))
|
|
10
|
+
const total = points.reduce((sum, point) => sum + point.count, 0)
|
|
11
|
+
return (
|
|
12
|
+
<figure>
|
|
13
|
+
<div className="flex h-24 items-end gap-0.5" role="img" aria-label={`${total} orders in ${days} days`}>
|
|
14
|
+
{points.map((point) => (
|
|
15
|
+
<div key={point.bucket} title={`${point.bucket.slice(0, 10)}: ${point.count}`} className="flex-1 rounded-t-sm bg-primary/70" style={{ height: `${(point.count / highest) * 100}%` }} />
|
|
16
|
+
))}
|
|
17
|
+
</div>
|
|
18
|
+
<figcaption className="mt-2 text-xs text-muted-foreground">{total.toLocaleString('en-IE')} orders</figcaption>
|
|
19
|
+
</figure>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { bottomBar } from '@protobase/schema'
|
|
2
|
+
|
|
3
|
+
/** On phones, the warehouse floor's screens as tabs; "More" opens the rest. */
|
|
4
|
+
export const warehouseBar = bottomBar({ label: 'Warehouse', roles: ['warehouse'] }, (m) => [
|
|
5
|
+
m.resource('stock_levels', { label: 'Stock' }),
|
|
6
|
+
m.resource('stock_moves', { label: 'Moves' }),
|
|
7
|
+
m.resource('orders', { label: 'Orders' }),
|
|
8
|
+
m.resource('locations', { label: 'Locations' }),
|
|
9
|
+
])
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** catalog.categories. Unique: (organization_id, slug) */
|
|
5
|
+
export const categories = resource('categories')
|
|
6
|
+
.table('catalog.categories')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.integer().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
parent_id: f.relation('categories').optional().filterable().sortable(),
|
|
11
|
+
name: f.text(),
|
|
12
|
+
slug: f.text().filterable().sortable(),
|
|
13
|
+
position: f.integer().default(0).sortable(),
|
|
14
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
15
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
16
|
+
})
|
|
17
|
+
.primaryKey((r) => r.id)
|
|
18
|
+
.tenant((r) => r.organization_id)
|
|
19
|
+
.position((r) => r.position, { within: (r) => r.parent_id })
|
|
20
|
+
.access(erpAccess('categories'))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { l, view } from '@protobase/schema'
|
|
2
|
+
import type { categories } from './data'
|
|
3
|
+
|
|
4
|
+
export const categoriesView = view<typeof categories>('categories')
|
|
5
|
+
.names({ singular: 'Category', plural: 'Categories' })
|
|
6
|
+
.list((r) => ({ columns: [r.name, r.slug, r.position] }))
|
|
7
|
+
.layout((r) => [
|
|
8
|
+
l.section('Category', [r.name, r.slug, r.parent_id, r.position]),
|
|
9
|
+
// Reordered by hand with its Reorder button, since each category's subcategories have an order of their own
|
|
10
|
+
l.related('Subcategories', { resource: 'categories', field: 'parent_id', sort: 'position', columns: ['name', 'slug'] }),
|
|
11
|
+
])
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** crm.companies */
|
|
5
|
+
export const companies = resource('companies')
|
|
6
|
+
.table('crm.companies')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.bigint().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
name: f.text().filterable(),
|
|
11
|
+
country_code: f.relation('countries').filterable().sortable(),
|
|
12
|
+
city: f.text(),
|
|
13
|
+
vat_number: f.text().optional(),
|
|
14
|
+
email: f.text().optional(),
|
|
15
|
+
phone: f.text().optional(),
|
|
16
|
+
status: f.enum(['lead', 'active', 'dormant']).default('active').filterable().sortable(),
|
|
17
|
+
created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
|
|
18
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
19
|
+
})
|
|
20
|
+
.primaryKey((r) => r.id)
|
|
21
|
+
.tenant((r) => r.organization_id)
|
|
22
|
+
.search((r) => [r.name, r.city])
|
|
23
|
+
.access(erpAccess('companies'))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { l, view } from '@protobase/schema'
|
|
2
|
+
import type { companies } from './data'
|
|
3
|
+
|
|
4
|
+
export const companiesView = view<typeof companies>('companies')
|
|
5
|
+
.title((r) => r.name)
|
|
6
|
+
.names({ singular: 'Company', plural: 'Companies' })
|
|
7
|
+
.fields((r) => ({
|
|
8
|
+
name: r.name.help('The registered name, as it should appear on invoices.'),
|
|
9
|
+
status: r.status.help('Leads have not ordered yet; dormant companies have not ordered in a year.').format('badge').valueLabels({ lead: 'Lead', active: 'Active', dormant: 'Dormant' }),
|
|
10
|
+
country_code: r.country_code.label('Country'),
|
|
11
|
+
vat_number: r.vat_number.label('VAT number').help('Required for invoices to other EU countries.').format('code'),
|
|
12
|
+
created_at: r.created_at.label('Created').format('absolute'),
|
|
13
|
+
}))
|
|
14
|
+
.list((r) => ({
|
|
15
|
+
columns: [r.name, r.city, r.country_code, r.status, r.email, r.phone, r.created_at],
|
|
16
|
+
search: [r.name],
|
|
17
|
+
}))
|
|
18
|
+
.filters((r, w) => [w.facets(r.status), w.facets(r.country_code)])
|
|
19
|
+
.layout((r) => [
|
|
20
|
+
l.section('Company', [r.name, r.status, r.city, r.country_code, r.vat_number], { help: 'Legal details used on quotes and invoices.' }),
|
|
21
|
+
l.section('Contact', [r.email, r.phone], { help: 'General contact details. People at the company are listed separately.' }),
|
|
22
|
+
])
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** crm.company_tags */
|
|
5
|
+
export const companyTags = resource('company_tags')
|
|
6
|
+
.table('crm.company_tags')
|
|
7
|
+
.fields({
|
|
8
|
+
company_id: f.relation('companies').filterable().sortable(),
|
|
9
|
+
tag_id: f.relation('tags').filterable().sortable(),
|
|
10
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
11
|
+
added_by: f.relation('users').optional().filterable().sortable(),
|
|
12
|
+
added_at: f.timestamp().dbDefault(),
|
|
13
|
+
})
|
|
14
|
+
.primaryKey((r) => [r.company_id, r.tag_id])
|
|
15
|
+
.tenant((r) => r.organization_id)
|
|
16
|
+
.access(erpAccess('company_tags'))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { companyTags } from './data'
|
|
3
|
+
|
|
4
|
+
export const companyTagsView = view<typeof companyTags>('company_tags')
|
|
5
|
+
.nav({ hidden: true })
|
|
6
|
+
.names({ singular: 'Company tag', plural: 'Company tags' })
|
|
7
|
+
.list((r) => ({ columns: [r.added_at, r.added_by] }))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** core.countries */
|
|
5
|
+
export const countries = resource('countries')
|
|
6
|
+
.table('core.countries')
|
|
7
|
+
.fields({
|
|
8
|
+
code: f.text().filterable().sortable(),
|
|
9
|
+
name: f.text(),
|
|
10
|
+
eu_member: f.boolean().default(false),
|
|
11
|
+
})
|
|
12
|
+
.primaryKey((r) => r.code)
|
|
13
|
+
.access(erpAccess('countries'))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** core.currencies */
|
|
5
|
+
export const currencies = resource('currencies')
|
|
6
|
+
.table('core.currencies')
|
|
7
|
+
.fields({
|
|
8
|
+
code: f.text().filterable().sortable(),
|
|
9
|
+
name: f.text(),
|
|
10
|
+
symbol: f.text(),
|
|
11
|
+
decimals: f.integer().default(2),
|
|
12
|
+
})
|
|
13
|
+
.primaryKey((r) => r.code)
|
|
14
|
+
.access(erpAccess('currencies'))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { currencies } from './data'
|
|
3
|
+
|
|
4
|
+
export const currenciesView = view<typeof currencies>('currencies')
|
|
5
|
+
.names({ singular: 'Currency', plural: 'Currencies' })
|
|
6
|
+
.list((r) => ({ columns: [r.name, r.symbol, r.decimals] }))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess, roles } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** hr.EMP_MASTER */
|
|
5
|
+
export const empMaster = resource('emp_master')
|
|
6
|
+
.table('hr.EMP_MASTER')
|
|
7
|
+
.fields({
|
|
8
|
+
emp_id: f.integer().column('EMP_ID').filterable().sortable(),
|
|
9
|
+
organization_id: f.relation('organizations').column('ORGANIZATION_ID').filterable().sortable(),
|
|
10
|
+
emp_name: f.text().column('EMP_NAME'),
|
|
11
|
+
dept_cd: f.text().column('DEPT_CD'),
|
|
12
|
+
hire_dt: f.date().column('HIRE_DT'),
|
|
13
|
+
sal_amt: f.decimal({ precision: 10, scale: 2 }).optional().column('SAL_AMT').access({ read: roles.is('admin') }),
|
|
14
|
+
mgr_id: f.relation('emp_master').optional().column('MGR_ID').filterable().sortable(),
|
|
15
|
+
active_flg: f.enum(['Y', 'N']).default('Y').column('ACTIVE_FLG'),
|
|
16
|
+
})
|
|
17
|
+
.primaryKey((r) => r.emp_id)
|
|
18
|
+
.tenant((r) => r.organization_id)
|
|
19
|
+
.access(erpAccess('emp_master'))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { empMaster } from './data'
|
|
3
|
+
|
|
4
|
+
export const empMasterView = view<typeof empMaster>('emp_master')
|
|
5
|
+
.names({ singular: 'Emp master', plural: 'Emp master' })
|
|
6
|
+
.fields((r) => ({ active_flg: r.active_flg.label('Active').valueLabels({ Y: 'Yes', N: 'No' }) }))
|
|
7
|
+
.list((r) => ({ columns: [r.emp_name, r.dept_cd, r.hire_dt, r.sal_amt, r.active_flg] }))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export { categories } from './categories/data'
|
|
2
|
+
export { categoriesView } from './categories/ui'
|
|
3
|
+
export { products } from './products/data'
|
|
4
|
+
export { productsView } from './products/ui'
|
|
5
|
+
export { countries } from './countries/data'
|
|
6
|
+
export { countriesView } from './countries/ui'
|
|
7
|
+
export { currencies } from './currencies/data'
|
|
8
|
+
export { currenciesView } from './currencies/ui'
|
|
9
|
+
export { organizations } from './organizations/data'
|
|
10
|
+
export { organizationsView } from './organizations/ui'
|
|
11
|
+
export { users } from './users/data'
|
|
12
|
+
export { usersView } from './users/ui'
|
|
13
|
+
export { companies } from './companies/data'
|
|
14
|
+
export { companiesView } from './companies/ui'
|
|
15
|
+
export { companyTags } from './company_tags/data'
|
|
16
|
+
export { companyTagsView } from './company_tags/ui'
|
|
17
|
+
export { people } from './people/data'
|
|
18
|
+
export { peopleView } from './people/ui'
|
|
19
|
+
export { tags } from './tags/data'
|
|
20
|
+
export { tagsView } from './tags/ui'
|
|
21
|
+
export { empMaster } from './emp_master/data'
|
|
22
|
+
export { empMasterView } from './emp_master/ui'
|
|
23
|
+
export { locations } from './locations/data'
|
|
24
|
+
export { locationsView } from './locations/ui'
|
|
25
|
+
export { stockLevels } from './stock_levels/data'
|
|
26
|
+
export { stockLevelsView } from './stock_levels/ui'
|
|
27
|
+
export { stockMoves } from './stock_moves/data'
|
|
28
|
+
export { stockMovesView } from './stock_moves/ui'
|
|
29
|
+
export { warehouses } from './warehouses/data'
|
|
30
|
+
export { warehousesView } from './warehouses/ui'
|
|
31
|
+
export { invoiceLines } from './invoice_lines/data'
|
|
32
|
+
export { invoiceLinesView } from './invoice_lines/ui'
|
|
33
|
+
export { invoices } from './invoices/data'
|
|
34
|
+
export { invoicesView } from './invoices/ui'
|
|
35
|
+
export { orderLines } from './order_lines/data'
|
|
36
|
+
export { orderLinesView } from './order_lines/ui'
|
|
37
|
+
export { orders } from './orders/data'
|
|
38
|
+
export { ordersView } from './orders/ui'
|
|
39
|
+
export { accountMenu } from './user-menu'
|
|
40
|
+
export { usersPage } from '@protobase/layout'
|
|
41
|
+
export { warehouseBar } from './bottom-bar'
|
|
42
|
+
export { salesOverview } from './overview/page'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** sales.invoice_lines. Unique: (invoice_id, position) */
|
|
5
|
+
export const invoiceLines = resource('invoice_lines')
|
|
6
|
+
.table('sales.invoice_lines')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.bigint().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
invoice_id: f.relation('invoices').filterable().sortable(),
|
|
10
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
11
|
+
position: f.integer().sortable(),
|
|
12
|
+
product_id: f.relation('products').optional().filterable().sortable(),
|
|
13
|
+
description: f.text(),
|
|
14
|
+
quantity: f.integer(),
|
|
15
|
+
unit_price: f.decimal({ precision: 12, scale: 2 }),
|
|
16
|
+
line_total: f.decimal({ precision: 14, scale: 2 }).optional().readOnly(),
|
|
17
|
+
created_at: f.timestamp().readOnly().dbDefault(),
|
|
18
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
19
|
+
})
|
|
20
|
+
.primaryKey((r) => r.id)
|
|
21
|
+
.tenant((r) => r.organization_id)
|
|
22
|
+
.position((r) => r.position, { within: (r) => r.invoice_id })
|
|
23
|
+
.access(erpAccess('invoice_lines'))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { view } from '@protobase/schema'
|
|
2
|
+
import type { invoiceLines } from './data'
|
|
3
|
+
|
|
4
|
+
export const invoiceLinesView = view<typeof invoiceLines>('invoice_lines')
|
|
5
|
+
.nav({ hidden: true })
|
|
6
|
+
.names({ singular: 'Invoice line', plural: 'Invoice lines' })
|
|
7
|
+
.list((r) => ({ columns: [r.position, r.quantity, r.unit_price, r.line_total] }))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { f, resource } from '@protobase/schema'
|
|
2
|
+
import { erpAccess } from '../roles'
|
|
3
|
+
|
|
4
|
+
/** sales.invoices. Unique: (organization_id, number) */
|
|
5
|
+
export const invoices = resource('invoices')
|
|
6
|
+
.table('sales.invoices')
|
|
7
|
+
.fields({
|
|
8
|
+
id: f.bigint().readOnly().filterable().sortable().dbDefault(),
|
|
9
|
+
organization_id: f.relation('organizations').filterable().sortable(),
|
|
10
|
+
number: f.text().filterable().sortable(),
|
|
11
|
+
order_id: f.relation('orders').optional().filterable().sortable(),
|
|
12
|
+
company_id: f.relation('companies').filterable().sortable(),
|
|
13
|
+
status: f.enum(['draft', 'sent', 'paid', 'overdue']).default('draft').filterable().sortable(),
|
|
14
|
+
issued_at: f.date().filterable().sortable(),
|
|
15
|
+
due_at: f.date(),
|
|
16
|
+
vat_rate: f.decimal({ precision: 4, scale: 2 }).default('21'),
|
|
17
|
+
subtotal: f.decimal({ precision: 14, scale: 2 }).readOnly().default('0'),
|
|
18
|
+
vat: f.decimal({ precision: 14, scale: 2 }).readOnly().default('0'),
|
|
19
|
+
total: f.decimal({ precision: 14, scale: 2 }).readOnly().default('0'),
|
|
20
|
+
created_at: f.timestamp().readOnly().filterable().sortable().dbDefault(),
|
|
21
|
+
updated_at: f.timestamp().readOnly().dbDefault(),
|
|
22
|
+
})
|
|
23
|
+
.primaryKey((r) => r.id)
|
|
24
|
+
.tenant((r) => r.organization_id)
|
|
25
|
+
.search((r) => [r.number])
|
|
26
|
+
.access(erpAccess('invoices'))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { l, view } from '@protobase/schema'
|
|
2
|
+
import type { invoices } from './data'
|
|
3
|
+
|
|
4
|
+
export const invoicesView = view<typeof invoices>('invoices')
|
|
5
|
+
.title((r) => r.number)
|
|
6
|
+
.names({ singular: 'Invoice', plural: 'Invoices' })
|
|
7
|
+
.nav({ recent: { status: 'status', tones: { draft: 'neutral', sent: 'info', overdue: 'danger' }, filter: 'status != "paid"', orderBy: 'issued_at desc', limit: 3 } })
|
|
8
|
+
.fields((r) => ({
|
|
9
|
+
number: r.number.help('Assigned when the invoice is created.').format('code'),
|
|
10
|
+
company_id: r.company_id.label('Customer').help('The company that receives the invoice.'),
|
|
11
|
+
order_id: r.order_id.label('Order').help('The sales order this invoice bills, if any.'),
|
|
12
|
+
status: r.status.help('Sent invoices are emailed to the customer; overdue ones trigger reminders.').format('badge').valueLabels({ draft: 'Draft', sent: 'Sent', paid: 'Paid', overdue: 'Overdue' }),
|
|
13
|
+
issued_at: r.issued_at.label('Issue date').help('The date printed on the invoice.'),
|
|
14
|
+
due_at: r.due_at.label('Due date').help('Usually the issue date plus the payment terms.'),
|
|
15
|
+
vat_rate: r.vat_rate.label('VAT rate').help('Percentage added to the subtotal.').format('percent'),
|
|
16
|
+
subtotal: r.subtotal.prefix('€').decimals(2),
|
|
17
|
+
vat: r.vat.label('VAT').prefix('€').decimals(2),
|
|
18
|
+
total: r.total.help('Subtotal plus VAT.').prefix('€').decimals(2),
|
|
19
|
+
}))
|
|
20
|
+
.list((r) => ({
|
|
21
|
+
columns: [r.number, r.company_id, r.status, r.issued_at, r.due_at, r.total],
|
|
22
|
+
search: [r.number],
|
|
23
|
+
}))
|
|
24
|
+
.filters((r, w) => [w.facets(r.status), w.dateRange(r.issued_at, { presets: ['30d', '90d', 'quarter', 'year'] })])
|
|
25
|
+
.layout((r) => [
|
|
26
|
+
l.section('Billing details', [r.company_id, r.order_id, r.status, r.issued_at, r.due_at], {
|
|
27
|
+
help: 'Who is being billed and when payment is due. Changes are saved when you press Save.',
|
|
28
|
+
}),
|
|
29
|
+
l.section('Totals', [r.vat_rate, r.subtotal, r.vat, r.total], {
|
|
30
|
+
help: 'Calculated from the invoice lines. The VAT rate applies to the whole invoice.',
|
|
31
|
+
}),
|
|
32
|
+
l.sidebar([r.status, r.total]),
|
|
33
|
+
])
|
|
34
|
+
.saveFeedback('button')
|
|
35
|
+
.actions((a) => [
|
|
36
|
+
a.action('send', { label: 'Send to customer', icon: 'send', confirm: 'Email the PDF to the customer and mark the invoice as sent?' }),
|
|
37
|
+
a.update('recordPayment', { label: 'Record payment', icon: 'wallet', set: { status: 'paid' } }),
|
|
38
|
+
])
|