initial commit

This commit is contained in:
CodedSakura 2026-11-01 21:59:34 +02:00
commit 3315375382
21 changed files with 2866 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
inventory.sqlite
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
node_modules

10
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="inventory.sqlite" uuid="93ba97d4-234b-414f-883d-75f31320c1fa">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:inventory.sqlite</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

7
.idea/misc.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/stockkeeping.iml" filepath="$PROJECT_DIR$/.idea/stockkeeping.iml" />
</modules>
</component>
</project>

6
.idea/sqldialects.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="SQLite" />
</component>
</project>

8
.idea/stockkeeping.iml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

4
.idea/watcherTasks.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

1000
.yarn/releases/yarn-4.18.0.cjs vendored Executable file

File diff suppressed because one or more lines are too long

10
.yarnrc.yml Normal file
View File

@ -0,0 +1,10 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
yarnPath: .yarn/releases/yarn-4.18.0.cjs

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"packageManager": "yarn@4.18.0",
"dependencies": {
"@hono/node-server": "^2.1.5",
"@types/node": "^26.2.0",
"hono": "^4.13.16",
"html5-qrcode": "^2.3.8",
"sass": "^1.107.1",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vite": "^8.3.5"
},
"scripts": {
"fe:dev": "vite",
"fe:build": "vite build",
"be:dev": "tsx watch src/main.ts"
}
}

78
shared/types.ts Normal file
View File

@ -0,0 +1,78 @@
export interface Location {
id: number, // integer
cabinet: string | null,
drawer: string | null,
bin: string | null,
}
export const locationColumns = [
// id omitted on purpose
"cabinet",
"drawer",
"bin",
] as const satisfies (keyof Location)[];
export interface Component {
id: number, // integer
partNumber: string,
manufacturer: string | null,
description: string | null,
packaging: string | null,
category: string | null,
package: string | null,
value: string | null,
quantity: number, // integer
unit: string,
location: number | null, // integer
datasheet: string | null,
}
export const componentColumns = [
// id omitted on purpose
"partNumber",
"manufacturer",
"description",
"packaging",
"category",
"package",
"value",
"quantity",
"unit",
"location",
"datasheet",
] as const satisfies (keyof Component)[];
export interface Project {
id: number, // integer
name: string,
}
export const projectColumns = [
"name",
] as const satisfies (keyof Project)[];
export interface ProjectComponent {
project: number, // integer
component: number, // integer
quantity: number, // integer
}
export const projectComponentColumns = [
"project",
"component",
"quantity",
] as const satisfies (keyof ProjectComponent)[];
export interface Movement {
id: number, // integer
component: number, // integer
quantity: number, // integer
date: number | null, // integer
reason: string | null,
project: number | null, // integer
price: number | null, // integer
}
export const movementColumns = [
"component",
"quantity",
"date",
"reason",
"project",
"price",
] as const satisfies (keyof Movement)[];

158
src/main.ts Normal file
View File

@ -0,0 +1,158 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { showRoutes } from "hono/dev";
import { DatabaseSync, SQLInputValue } from "node:sqlite";
import { componentColumns, locationColumns, movementColumns, projectColumns, projectComponentColumns } from "../shared/types";
const app = new Hono();
const api = new Hono().basePath("/api");
const db = new DatabaseSync("./inventory.sqlite");
const sql = db.createTagStore();
sql.run`PRAGMA foreign_keys = ON`;
const wrapWithTransaction = <T>(runnable: () => T): T => {
try {
sql.run`BEGIN`;
const result = runnable();
sql.run`COMMIT`;
return result;
} catch (e) {
sql.run`ROLLBACK`;
throw e;
}
};
const insertHelper = <K extends PropertyKey>(table: string, columns: K[], data: Record<K, SQLInputValue>) => {
return db.prepare(`
INSERT INTO ${table} (${columns.join(", ")})
VALUES (${columns.map(() => "?").join(", ")})
`).run(...columns.map(k => data[k] ?? null));
};
const patchHelper = <K extends PropertyKey>(table: string, columns: K[], id: number, data: Partial<Record<K, SQLInputValue>>) => {
const validEntries = Object.entries(data).filter(([k]) => columns.includes(k as K));
if (validEntries.length === 0) throw new Error("Nothing to update");
return db.prepare(`
UPDATE ${table}
SET ${validEntries.map(([k]) => `${k} = ?`).join(", ")}
WHERE id = ?
`).run(...validEntries.map(([, v]) => v as any), id);
};
const tables: [string, string[]][] = [
[ "location", locationColumns ],
[ "component", componentColumns ],
[ "project", projectColumns ],
[ "project_component", projectComponentColumns ],
[ "movement", movementColumns ],
];
for (const [ tableName, columns ] of tables) {
const path = `/${tableName.replace(/_(.)/g, (_, v) => v.toUpperCase())}`;
api.get(`${path}`, c => {
const { limit = "50", offset = "0" } = c.req.query();
const parsedLimit = Math.max(0, Number(limit) || 50);
const parsedOffset = Math.max(0, Number(offset) || 50);
const stmt = db.prepare(`SELECT * FROM ${tableName} LIMIT ? OFFSET ?`);
const countStmt = db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`);
return c.json({
data: stmt.all(parsedLimit, parsedOffset),
limit: parsedLimit,
offset: parsedOffset,
count: countStmt.get()!.count,
});
});
api.post(`${path}`, async c => {
const data = await c.req.json();
const { lastInsertRowid } = insertHelper(tableName, columns, data);
return c.json({ id: lastInsertRowid });
});
api.get(`${path}/:id`, c => {
const id = c.req.param("id");
const stmt = db.prepare(`SELECT * FROM ${tableName} WHERE id = ?`);
return c.json(stmt.get(id));
});
api.patch(`${path}/:id`, async c => {
const id = parseInt(c.req.param("id"));
const data = await c.req.json();
patchHelper(tableName, columns, id, data);
return c.json({});
});
}
{
sql.run`CREATE TABLE IF NOT EXISTS version ( version INTEGER NOT NULL ) STRICT`;
let version = sql.get`SELECT version FROM version LIMIT 1`?.version;
console.log(`Current database version: ${version}`);
if (version === undefined) {
wrapWithTransaction(() => {
sql.run`CREATE TABLE location (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
cabinet TEXT,
drawer TEXT,
bin TEXT
) STRICT`;
sql.run`CREATE TABLE component (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
partNumber TEXT NOT NULL,
manufacturer TEXT,
description TEXT,
packaging TEXT,
category TEXT,
package TEXT,
value TEXT,
quantity INTEGER NOT NULL,
unit TEXT NOT NULL,
location INTEGER,
datasheet TEXT,
FOREIGN KEY (location) REFERENCES location(id)
) STRICT`;
sql.run`CREATE INDEX component_location_idx ON component(location)`;
sql.run`CREATE TABLE project (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
) STRICT`;
sql.run`CREATE TABLE project_component (
project INTEGER NOT NULL,
component INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
PRIMARY KEY (project, component),
FOREIGN KEY (component) REFERENCES component(id),
FOREIGN KEY (project) REFERENCES project(id)
) STRICT`;
sql.run`CREATE INDEX project_component_component_idx ON project_component(component)`;
sql.run`CREATE TABLE movement (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
component INTEGER NOT NULL,
quantity INTEGER NOT NULL,
date INTEGER,
reason TEXT,
project INTEGER,
price INTEGER,
FOREIGN KEY (component) REFERENCES component(id),
FOREIGN KEY (project) REFERENCES project(id)
) STRICT`;
sql.run`CREATE INDEX movement_component_idx ON movement(component)`;
sql.run`CREATE INDEX movement_project_idx ON movement(project)`;
// language=SQLite - no idea why it's not doing that automatically here
sql.run`INSERT INTO version (version) VALUES (1)`;
console.log("initialized database to version 1");
version = 1;
});
}
}
app.route("/", api);
showRoutes(app);
serve({
fetch: app.fetch,
port: Number(process.env.PORT ?? 3000),
}, ({ port }) => {
console.log(`Listening on port ${port}`);
});

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}

18
vite.config.mts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
export default defineConfig({
root: "web",
server: {
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
build: {
outDir: "../dist",
emptyOutDir: true,
},
});

52
web/index.html Normal file
View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stock-keeping</title>
<link rel="stylesheet" href="style.scss">
</head>
<body>
<div>
Components:
<table>
<thead>
<tr>
<th>ID</th>
<th>P/N</th>
<th>description</th>
<th>packaging</th>
<th>category</th>
<th>footprint</th>
<th>value</th>
<th>quantity</th>
<th>unit</th>
<th>location</th>
<th>datasheet</th>
</tr>
</thead>
<tbody>
<template data-table-template="component">
<tr>
<td data-field="id" data-type="id">-</td>
<td data-field="partNumber">-</td>
<td data-field="description">-</td>
<td data-field="packaging">-</td>
<td data-field="category" data-type="category">-</td>
<td data-field="package">-</td>
<td data-field="value">-</td>
<td data-field="quantity">-</td>
<td data-field="unit">-</td>
<td data-field="location" data-type="location">-</td>
<td data-field="datasheet" data-type="url">-</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="modal">
</div>
<script src="main.ts"></script>
</body>
</html>

4
web/main.ts Normal file
View File

@ -0,0 +1,4 @@
window.addEventListener("load", () => {
console.log("im here!");
fetch("/api/component").then(e => e.json()).then(console.log);
});

6
web/style.scss Normal file
View File

@ -0,0 +1,6 @@
:root {
}
body {
margin: 0;
}

1436
yarn.lock Normal file

File diff suppressed because it is too large Load Diff