diff --git a/.claude/hooks/check-docker.sh b/.claude/hooks/check-docker.sh new file mode 100755 index 0000000..f2404db --- /dev/null +++ b/.claude/hooks/check-docker.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Pre-test check: Docker daemon + Postgres container must be running + +if ! docker ps > /dev/null 2>&1; then + echo "Docker is not running. Start Docker Desktop on Windows, wait ~15 seconds for WSL2 integration, then retry." >&2 + exit 2 +fi + +if ! docker ps --filter "name=wheretf-postgres" --filter "status=running" | grep -q postgres; then + echo "Postgres container is not running. Start it with: docker compose -f docker-compose.dev.yml up -d" >&2 + exit 2 +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..26115c9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -qE '(npm test|npx vitest|vitest run)' && /home/nick/projects/nickydoes/wheretf/.claude/hooks/check-docker.sh || true" + } + ] + } + ] + }, + "permissions": { + "allow": [ + "Bash(DATABASE_URL=\"postgresql://wheretf:wheretf@localhost:5432/wheretf_test\" npx vitest run)", + "WebSearch" + ], + "additionalDirectories": [ + "/home/nick/projects/nickydoes/wheretf/web/app/api/modules" + ] + } +} diff --git a/.claude/skills/committing.md b/.claude/skills/committing.md new file mode 100644 index 0000000..5a08d28 --- /dev/null +++ b/.claude/skills/committing.md @@ -0,0 +1,4 @@ +# Commit Guidelines + +- Do not include Co-Authored-By lines or any AI/Claude attribution in commit messages. +- Keep commit messages concise and focused on the "why" of the change. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bc53b1d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +name: CI + +on: + push: + branches: [main, v2-rewrite] + tags: ["v*"] + pull_request: + branches: [main] + +permissions: + contents: read + packages: write # to push to GHCR on the publish job + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + REGISTRY: ghcr.io + APP_IMAGE: ghcr.io/${{ github.repository }}/web + MIGRATE_IMAGE: ghcr.io/${{ github.repository }}/migrate + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: web/package-lock.json + - run: npm ci --prefer-offline --no-audit --no-fund + working-directory: web + - run: npm run lint + working-directory: web + - run: npx tsc --noEmit + working-directory: web + + test-backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: wheretf_test + POSTGRES_USER: wheretf + POSTGRES_PASSWORD: wheretf + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U wheretf" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + DATABASE_URL: postgresql://wheretf:wheretf@localhost:5432/wheretf_test + DATABASE_URL_TEST: postgresql://wheretf:wheretf@localhost:5432/wheretf_test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: web/package-lock.json + - run: npm ci --prefer-offline --no-audit --no-fund + working-directory: web + - run: npm run db:migrate + working-directory: web + - run: npm run test:backend + working-directory: web + + test-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: web/package-lock.json + - run: npm ci --prefer-offline --no-audit --no-fund + working-directory: web + - run: npm run test:frontend + working-directory: web + + build-and-publish: + runs-on: ubuntu-latest + needs: [lint, test-backend, test-frontend] + # Only publish images on main / tags — PRs run lint+tests only. + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU (for arm64 emulation) + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Tag metadata (app) + id: meta-app + uses: docker/metadata-action@v5 + with: + images: ${{ env.APP_IMAGE }} + tags: | + type=sha,prefix=sha- + type=ref,event=branch + type=ref,event=tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build + push runner image + uses: docker/build-push-action@v6 + with: + context: ./web + target: runner + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-app.outputs.tags }} + labels: ${{ steps.meta-app.outputs.labels }} + cache-from: type=gha,scope=runner + cache-to: type=gha,scope=runner,mode=max + + - name: Tag metadata (migrate) + id: meta-migrate + uses: docker/metadata-action@v5 + with: + images: ${{ env.MIGRATE_IMAGE }} + tags: | + type=sha,prefix=sha- + type=ref,event=branch + type=ref,event=tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build + push migrator image + uses: docker/build-push-action@v6 + with: + context: ./web + target: migrator + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-migrate.outputs.tags }} + labels: ${{ steps.meta-migrate.outputs.labels }} + cache-from: type=gha,scope=migrator + cache-to: type=gha,scope=migrator,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..993dd78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +*.swp +*.swo +*~ + +# Claude Code local state +.claude/settings.local.json +.claude/project-context.json + +# Environment +.env +.env.* +!.env.*.example + +# Data / runtime +data/ + +# Node (root-level scripts) +node_modules/ + +# Docker volumes +docker-data/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..624d7f2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# WhereTF — Development Directives + +## Claude project notes +- Keep this file under 150 lines. +- Prefer higher level CLAUDE.md files for generalized instructions + +## Folder structure +- Specifications belong in `specification/` + +## Stack + +Next.js (App Router) + React, PostgreSQL, Tailwind v4. Storage grid rendered as SVG within React components; DOM overlays for tooltips and detail panels. Single-user for initial implementation. Designed for future multi-user, multi-tenant (users belong to orgs). AI integration (OpenAI) deferred — build core storage and item management first. + +## Dev Commands + +From `web/`: +- `npm run dev` — next dev (requires local PostgreSQL) +- `npm test` — Vitest unit and integration tests +- `npm run test:watch` — Vitest in watch mode +- `npm run db:migrate` — run Drizzle migrations +- `npm run db:generate` — generate migration from schema changes +- `npm run db:studio` — Drizzle Studio (database browser) + +## Testing + +TDD for data model and repository layers. Tests run against a real PostgreSQL database with per-test transaction rollback — no mocks, no in-memory fakes. + +- **Unit tests** — repository functions, domain logic, path utilities +- **Integration tests** — API routes, multi-step operations (insert placement, assignment resolution) +- **Component tests** — React Testing Library + Vitest, test behavior not rendering + +## Architecture + +Three-layer data access — no exceptions: + +- **Schema** (`web/db/schema/`) — Drizzle schema definitions, no business logic +- **Repository** (`web/repositories/`) — All business logic, validation, org-scoped queries +- **API route** (`web/app/api/`) — Thin: parse request, check auth, call repository, format response + +## Repository Conventions + +- All methods take a single destructured object: `create({ userId, name, location })` +- All user-data queries must scope by `userId` — no unscoped queries. Future: `orgId` scoping when multi-tenant is implemented; items will be global (shared across orgs). +- Repositories throw errors; API routes catch and return `{ error: "msg" }` + +## API Response Shape + +``` +Success: { items: [...] } or { item: {...} } +Error: { error: "Error message" } +``` + +## Naming + +| Type | Convention | Example | +|------|-----------|---------| +| Components, models | PascalCase | `ChatContainer.tsx`, `Module.ts` | +| Utilities, functions, variables | camelCase | `agentRunner.ts`, `userId` | +| Constants | UPPER_SNAKE | `MAX_TOKENS` | +| Database fields | camelCase | `createdAt` | +| Module names (domain) | Short, not descriptive | `MUSE`, `FLUX`, `NEON` | +| Parameter keys (domain) | lowercase_underscore | `thread_size`, `voltage_rating` | + +## Styling + +Tailwind v4 with custom `accent` color (#ff6600 orange). Dark mode supported. Grid labeling: rows=alpha (A,B,C), cols=numeric (1,2,3), origin=top-left. + +## Adding Things + +**New model:** Drizzle schema in `web/db/schema/` → generate migration → repository in `web/repositories/` → API routes in `web/app/api/` → tests first + +## Specification + +- [specification/project-intent.md](specification/project-intent.md) — what WhereTF is, interaction model, domain concepts +- [specification/storage-model.md](specification/storage-model.md) — storage data model (modules, templates, inserts, overrides, paths) +- [specification/deployment.md](specification/deployment.md) — CI/CD pipeline and deployment +- [specification/storage-navigator-design.md](specification/storage-navigator-design.md) — grid visualization UI/UX spec +- [specification/storage-definition-design.md](specification/storage-definition-design.md) — module/template/insert definition UI/UX spec +- [specification/item-taxonomy.md](specification/item-taxonomy.md) — item classification (categories, parameters, aspects) +- [specification/item-maintenance.md](specification/item-maintenance.md) — item lifecycle use cases +- [specification/item-management-design.md](specification/item-management-design.md) — item management UI/UX spec +- [specification/ui-paradigms.md](specification/ui-paradigms.md) — cross-cutting UI/UX rules +- [specification/ai-agent-architecture.md](specification/ai-agent-architecture.md) — AI agent patterns (deferred, reference only) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..281f820 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,28 @@ +# Code of Conduct + +## In short + +Be decent. Don't be a jerk. + +## Longer version + +- Assume good faith. People miscommunicate, especially over text. +- Disagreement about technical choices is fine and expected. Framing + disagreement as attacks on a person is not. +- No harassment, no slurs, no threats, no doxxing. Obvious. +- This project has one maintainer. Decisions that can't be resolved + by discussion fall to them. If you don't like a decision, fork — + that's your right under AGPL. + +## Scope + +Applies to interactions in this repo (issues, PRs, discussions) and +any WhereTF-branded channels elsewhere. + +## Enforcement + +Report violations by emailing the maintainer (address in the commit +log). Warnings escalate to ban from project spaces. No appeals +process — this is a solo-run project, not a committee. + +— The Maintainer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a7c4fe3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing + +Glad you're here. WhereTF is a solo-maintained project that welcomes +contributions from anyone who finds it useful. The bar for quality +isn't high — *working* beats *polished* — but there are a few rules +that keep things sane. + +## Getting started + +1. Fork the repo. +2. Clone your fork, branch from `main`: + ```bash + git checkout -b feat/your-thing + ``` +3. Set up a dev env: + ```bash + docker compose -f docker-compose.dev.yml up -d # postgres + cd web + npm install + npm run db:migrate + npm run dev + ``` +4. Make your change. Small, focused commits are easier to review. +5. Run tests: + ```bash + npm run test:backend + npm run test:frontend + ``` +6. Push and open a pull request. + +## What makes a good PR + +- **One idea per PR.** Splitting a refactor from a feature change + makes review tractable. +- **Matches existing patterns.** If you see three places doing + something a certain way, do it that way too. If you think the + pattern is wrong, open an issue first. +- **Tests where they matter.** Repository changes should have + integration tests against real Postgres. UI changes don't need + tests unless they're non-trivial logic. +- **No formatting PRs.** Not worth the review friction. +- **Passes CI.** `lint`, `test:backend`, `test:frontend`, and the + Docker build all have to go green. + +## What's in scope + +Yes: +- Bug fixes. +- UI improvements. +- New storage templates (Gridfinity sizes, common drawer dividers, + etc. — see `specification/storage-model.md`). +- New taxonomy content (common aspects, standards, designations). +- Documentation improvements. +- Performance fixes. + +Usually yes, ping first: +- Schema changes. Coordinate so migrations don't conflict. +- New external dependencies. Node is lean; let's keep it that way. +- Big architectural shifts. + +Probably no: +- Auth / multi-tenancy work — there's a plan in + `specification/deployment.md`, and it's part of the commercial + hosted service's differentiation. Happy to discuss if you have + ideas. +- Anything requiring cloud services (AWS, GCP, etc.) as a hard + dependency. Self-hostability is a feature. +- Adopting a heavy framework (Redux, TanStack Query, etc.). The + current plain-React-plus-fetch pattern is deliberate. + +## License + +By contributing, you agree that your code is licensed under the +same AGPL-3.0 as the rest of the project. No CLA — standard +inbound=outbound. + +## Commits + +- Imperative mood: "Add foo", not "Added foo" or "Adds foo". +- Body explains *why*, not *what* (the diff shows the what). +- No AI attribution. Sign off as yourself. +- Conventional commit prefixes optional but welcome (`feat:`, `fix:`, + `refactor:`, `docs:`). + +## Questions + +Open a GitHub issue or discussion before sinking time into +anything big. I'd rather chat first than reject a finished PR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e307c82 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# WhereTF + +**Remembers where you put your stuff so you don't have to.** + +A workshop item tracker for people with more bins than memory. Models +your physical storage (cabinets, drawers, Gridfinity, Plano boxes), +the items inside (screws, resistors, glue, whatever), and the +parametric characteristics that make two items "the same thing" — +so search, dedup, and find-me-that-part-I-saw-last-year all work. + +- **Storage:** Modules → Levels → Inserts → Cells. Drag-free grid UI. +- **Items:** name + categories + applied aspects (parameter groups) + + standards + designations. Parametric, searchable, comparable. +- **Identification:** pick a standard designation (e.g. ISO 4762 + cap screw, M3×0.5×10) and parameter values auto-fill. Generate a + whole bolt set with one click. +- **Taxonomy audit:** find duplicate aspects, near-duplicate + parameters, units you forgot to add, enum values you never listed. + +Stack: Next.js 16 (App Router, React 19, Tailwind v4) + PostgreSQL + +Drizzle ORM. Dark theme. Single-container deploy. No auth yet — do +not expose to the internet. + +--- + +## Status + +**Early. Single-user, homelab-scale.** Core storage + item model + +taxonomy flows work. Multi-tenancy, auth, rate-limiting, and the +hosted service are all planned (see [`specification/deployment.md`](specification/deployment.md)). + +Open to contributions — see [CONTRIBUTING.md](CONTRIBUTING.md). + +--- + +## Quick start (Docker) + +Requires Docker Engine ≥ 24 and Docker Compose v2. + +```bash +git clone https://github.com/ndemarco/wheretf.git +cd wheretf +docker compose up --build -d +open http://localhost:3000 +``` + +That brings up: +- A throwaway `postgres:16` container with a named volume. +- A one-shot migration task (applies all schema migrations, exits 0). +- The app on port 3000. + +Tear down with `docker compose down -v` (the `-v` wipes the DB +volume). + +### Running the dev server instead + +If you want to hack on the code with hot-reload, use the dev compose: + +```bash +docker compose -f docker-compose.dev.yml up -d # postgres only +cd web +npm install +npm run db:migrate +npm run dev # http://localhost:3000 +``` + +### Tests + +```bash +cd web +npm run test:backend # integration tests against a real postgres +npm run test:frontend # component + util tests, jsdom +``` + +Backend tests require `wheretf_test` DB on `localhost:5432`. Safety +guard in `tests/setup.ts` refuses to truncate any DB whose name +doesn't contain `test`. + +--- + +## Deployment + +See [`specification/deployment.md`](specification/deployment.md) for +the full contract. Short version: + +- Single Docker image published to + `ghcr.io/ndemarco/wheretf/web:` and + `ghcr.io/ndemarco/wheretf/migrate:`. +- Multi-arch: `linux/amd64` + `linux/arm64`. +- Configured entirely via `DATABASE_URL`. No secrets baked into + the image. +- `/api/health` (liveness) and `/api/health/ready` (Postgres round-trip). +- Postgres 15–17 supported; 16 is the target. + +--- + +## Docs + +Specs live in [`specification/`](specification/). + +- [`project-intent.md`](specification/project-intent.md) — what this is + and why, the mental model. +- [`storage-model.md`](specification/storage-model.md) — storage data + model (modules, templates, inserts, overrides, paths). +- [`item-taxonomy.md`](specification/item-taxonomy.md) — items, + parameters, aspects, standards, designations. +- [`deployment.md`](specification/deployment.md) — packaging + deploy + contract + future-work notes (auth, authz, multi-tenancy). +- [`ui-paradigms.md`](specification/ui-paradigms.md) — cross-cutting + UI rules. + +--- + +## License + +**GNU Affero General Public License v3.0.** See [LICENSE](LICENSE). + +In plain terms: you can use, study, modify, and redistribute this +code, including running it as a network service, provided you share +your modifications under the same license. Hosting a modified version +without publishing the source is not permitted. + +A separate commercial license is available for organizations that +can't use AGPL. Contact the maintainer. + +--- + +## Name + +"WhereTF" — short for *where to find*. Pronounced however you like. +Not endorsed by any regulatory body and mildly sassy on purpose. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..426b146 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_DB: wheretf + POSTGRES_USER: wheretf + POSTGRES_PASSWORD: wheretf + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf3da55 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +# Reference compose for running the WhereTF image against a local +# Postgres. The homelab deploy system should use its own orchestration +# (Portainer / Ansible / etc.) — this file is here so anyone can run +# the prod image end-to-end on a laptop with one command: +# +# docker compose up --build +# +# Then open http://localhost:3000. For ongoing dev (hot-reload against +# local sources) use docker-compose.dev.yml which only starts postgres. + +services: + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_DB: wheretf + POSTGRES_USER: wheretf + POSTGRES_PASSWORD: wheretf + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wheretf"] + interval: 5s + timeout: 3s + retries: 10 + + # One-shot job: applies drizzle migrations then exits. The app + # service waits for this to complete (service_completed_successfully) + # before starting. Deploy systems that prefer a separate migration + # task can run this container independently and skip the app waiting. + migrate: + build: + context: ./web + target: migrator + image: wheretf/migrate:local + environment: + DATABASE_URL: postgresql://wheretf:wheretf@postgres:5432/wheretf + depends_on: + postgres: + condition: service_healthy + restart: "no" + + app: + build: + context: ./web + image: wheretf/web:local + restart: unless-stopped + environment: + DATABASE_URL: postgresql://wheretf:wheretf@postgres:5432/wheretf + NODE_ENV: production + PORT: "3000" + ports: + - "3000:3000" + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + pgdata: diff --git a/inventory-system/.env.example b/inventory-system/.env.example deleted file mode 100644 index 9984914..0000000 --- a/inventory-system/.env.example +++ /dev/null @@ -1,21 +0,0 @@ -# Inventory System Environment Configuration -# Copy this file to .env and customize as needed - -# Database Configuration -DATABASE_URL=postgresql://inventoryuser:inventorypass@postgres:5432/inventory -POSTGRES_USER=inventoryuser -POSTGRES_PASSWORD=inventorypass -POSTGRES_DB=inventory - -# Flask Configuration -FLASK_ENV=development -FLASK_APP=app -SECRET_KEY=dev-secret-key-change-in-production - -# Application Port -PORT=5000 - -# Production Settings (uncomment for production) -# FLASK_ENV=production -# SECRET_KEY=your-secure-random-key-here -# DATABASE_URL=postgresql://user:password@host:5432/dbname diff --git a/inventory-system/.gitignore b/inventory-system/.gitignore deleted file mode 100644 index 77fe08c..0000000 --- a/inventory-system/.gitignore +++ /dev/null @@ -1,74 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual Environment -venv/ -env/ -ENV/ - -# IDEs -.vscode/ -.idea/ -.claude/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db -*:Zone.Identifier - -# Environment variables -.env -.env.local - -# Database -data/ -*.sql -*.db -*.sqlite3 - -# Logs -*.log -logs/ - -# Docker -.docker/ - -# Backup files -backup*.sql -backup*.tar.gz - -# Flask -instance/ -.webassets-cache - -# Testing -.pytest_cache/ -.coverage -htmlcov/ - -# Temporary files -*.tmp -temp/ -tmp/ diff --git a/inventory-system/README.md b/inventory-system/README.md deleted file mode 100644 index 97ab486..0000000 --- a/inventory-system/README.md +++ /dev/null @@ -1,406 +0,0 @@ -# 🏠 Homelab Inventory System - -A comprehensive inventory management system for homelab, makerspace, and workshop environments. Track thousands of items across organized storage modules with natural language search and AI-powered capabilities. - -## 🚀 Phase 1: Foundation (Current) - -This is **Phase 1** of an 8-phase development roadmap. The current release includes: - -- ✅ Complete storage hierarchy (Modules → Levels → Locations) -- ✅ Full CRUD operations for items, modules, levels, and locations -- ✅ Web UI with responsive design -- ✅ Basic keyword search -- ✅ Location visualization (grid view) -- ✅ Docker deployment ready -- ✅ PostgreSQL backend with proper relationships - -### Coming in Future Phases - -- 🔜 **Phase 2**: Smart location suggestions -- 🔜 **Phase 3**: Duplicate detection -- 🔜 **Phase 4**: Semantic search with AI embeddings -- 🔜 **Phase 5**: CLI interface -- 🔜 **Phase 6**: Voice interface -- 🔜 **Phase 7**: Advanced AI features -- 🔜 **Phase 8**: Production polish & mobile optimization - -## 📋 Prerequisites - -- Docker and Docker Compose -- Git (for cloning) -- 2GB RAM minimum -- 10GB disk space - -## 🏃 Quick Start - -### 1. Clone or Extract the Project - -If you have the project as files: -```bash -cd inventory-system -``` - -### 2. Start the System - -```bash -docker-compose up -d -``` - -This will: -- Start PostgreSQL database -- Build and start the Flask backend -- Start nginx reverse proxy - -### 3. Access the Application - -Open your browser and navigate to: -``` -http://localhost:8080 -``` - -### 4. Stop the System - -```bash -docker-compose down -``` - -To stop and remove all data: -```bash -docker-compose down -v -``` - -## 📚 Usage Guide - -### First Steps - -1. **Create a Module**: A module is a storage unit (cabinet, shelving unit, etc.) - - Navigate to "Modules" → "Add Module" - - Example: Name it "Zeus" or "Main Workbench" - -2. **Add Levels**: Levels are drawers, shelves, or compartments within a module - - View your module → "Add Level" - - Specify grid dimensions (rows × columns) - - Example: 4 rows × 6 columns creates locations A1-A6, B1-B6, etc. - -3. **Add Items**: Store your inventory items - - Navigate to "Items" → "Add Item" - - Provide a natural language description - - Optionally assign a storage location - - Example: "Pan head phillips screw, 3/4 inch long, #8, mild steel" - -4. **Search**: Find items quickly - - Use the search bar to find items by name, description, or tags - - View item locations on the results page - -### Storage Hierarchy - -``` -Module (e.g., "Zeus", "Muse") -├── Level 1 (e.g., drawer, shelf) -│ ├── Location A1 -│ ├── Location A2 -│ └── ... -├── Level 2 -│ ├── Location A1 -│ └── ... -└── ... -``` - -### Example: Adding a Screw - -1. Go to "Items" → "Add Item" -2. Fill in: - - **Name**: "Phillips Pan Head #8 Screw" - - **Description**: "Pan head phillips screw, 3/4 inch long, #8 diameter, mild steel" - - **Category**: "Fasteners" - - **Item Type**: "solid" - - **Quantity**: "100" - - **Unit**: "pieces" - - **Tags**: "screw, phillips, pan head, #8, fastener" - - **Location**: "Muse:4:A3" (Module: Muse, Level: 4, Location: A3) -3. Click "Create Item" - -### Location Types - -The system supports different location types for various storage needs: - -- **general**: Standard bins -- **small_box**: For tiny components (SMD parts, small hardware) -- **medium_bin**: Standard drawer compartments -- **large_bin**: Bulk storage -- **liquid_container**: For paints, solvents, coatings -- **smd_container**: Specialized for surface-mount components - -## 🗂️ Database Schema - -``` -modules -├── id -├── name (unique) -├── description -└── location_description - -levels -├── id -├── module_id → modules.id -├── level_number -├── rows -└── columns - -locations -├── id -├── level_id → levels.id -├── row -├── column -├── location_type -└── dimensions (width, height, depth) - -items -├── id -├── name -├── description -├── category -├── quantity -├── metadata (JSON) -└── tags - -item_locations (many-to-many) -├── item_id → items.id -├── location_id → locations.id -└── quantity -``` - -## 🔧 Configuration - -### Environment Variables - -Create a `.env` file in the project root: - -```env -# Database -DATABASE_URL=postgresql://inventoryuser:inventorypass@postgres:5432/inventory - -# Flask -FLASK_ENV=development -SECRET_KEY=your-secret-key-here - -# Port -PORT=5000 -``` - -### Changing Ports - -Edit `docker-compose.yml`: - -```yaml -services: - nginx: - ports: - - "8080:80" # Change 8080 to your preferred port -``` - -## 🐛 Troubleshooting - -### Database Connection Issues - -```bash -# Check if PostgreSQL is running -docker-compose ps - -# View PostgreSQL logs -docker-compose logs postgres - -# Restart PostgreSQL -docker-compose restart postgres -``` - -### Application Won't Start - -```bash -# View backend logs -docker-compose logs backend - -# Rebuild containers -docker-compose up --build - -# Reset everything -docker-compose down -v -docker-compose up --build -``` - -### Port Already in Use - -If port 8080 is in use: - -```bash -# Find what's using the port -lsof -i :8080 - -# Or change the port in docker-compose.yml -``` - -## 📊 API Endpoints - -The system provides REST API endpoints for programmatic access: - -### Modules -- `GET /modules/api/modules` - List all modules -- `GET /modules/api/modules/` - Get module details -- `GET /modules/api/modules//levels` - List module levels - -### Locations -- `GET /locations/api/locations` - List locations (with filters) -- `GET /locations/api/locations/` - Get location details - -### Items -- `GET /items/api/items` - List items (with search) -- `GET /items/api/items/` - Get item details - -### Search -- `GET /search/api?q=query` - Search items - -Example: -```bash -curl http://localhost:8080/items/api/items?search=screw -``` - -## 🚢 Deployment Options - -### Option 1: Local VPS/Server -```bash -# Clone and run -git clone -cd inventory-system -docker-compose up -d -``` - -### Option 2: Proxmox Container -1. Create an LXC container (Ubuntu 22.04+) -2. Install Docker and Docker Compose -3. Clone and run as above - -### Option 3: Jetson Nano -1. Install Docker on Jetson -2. Clone the repository -3. Run with docker-compose - -### Production Considerations - -For production deployment: - -1. **Change default passwords** in `docker-compose.yml` -2. **Set a secure SECRET_KEY** in environment variables -3. **Enable HTTPS** with Let's Encrypt -4. **Set up backups** for the PostgreSQL data volume -5. **Configure firewall rules** -6. **Use production WSGI server** (Gunicorn instead of Flask dev server) - -## 💾 Backup and Restore - -### Backup Database - -```bash -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql -``` - -### Restore Database - -```bash -docker-compose exec -T postgres psql -U inventoryuser inventory < backup.sql -``` - -### Backup Data Directory - -```bash -tar -czf backup-$(date +%Y%m%d).tar.gz data/ -``` - -## 🛠️ Development - -### Running Without Docker - -```bash -# Install PostgreSQL locally -# Create database 'inventory' - -# Install Python dependencies -cd backend -pip install -r requirements.txt - -# Set environment variable -export DATABASE_URL="postgresql://user:pass@localhost:5432/inventory" - -# Run application -python run.py -``` - -Access at http://localhost:5000 - -### Project Structure - -``` -inventory-system/ -├── backend/ -│ ├── app/ -│ │ ├── models.py # Database models -│ │ ├── routes/ # Route handlers -│ │ │ ├── main.py -│ │ │ ├── items.py -│ │ │ ├── modules.py -│ │ │ ├── locations.py -│ │ │ └── search.py -│ │ └── __init__.py -│ ├── requirements.txt -│ ├── Dockerfile -│ └── run.py -├── frontend/ -│ ├── templates/ # Jinja2 templates -│ └── static/ -│ ├── css/ -│ └── js/ -├── docker-compose.yml -├── nginx.conf -└── README.md -``` - -## 📖 Next Steps - -After getting comfortable with Phase 1: - -1. Add your first 50-100 items -2. Organize them into modules and levels -3. Test the search functionality -4. Provide feedback on what features you need most - -## 🐛 Known Limitations (Phase 1) - -- No AI-powered semantic search yet (coming in Phase 4) -- No duplicate detection (coming in Phase 3) -- No location suggestions (coming in Phase 2) -- No CLI or voice interface (coming in Phases 5-6) -- Basic keyword search only -- No user authentication (single-user system for now) - -## 🤝 Support - -For issues, questions, or feature requests, please open an issue in the project repository. - -## 📝 License - -[Your License Here] - -## 🎯 Roadmap - -- [x] Phase 1: Foundation (Current) -- [ ] Phase 2: Smart Location Management -- [ ] Phase 3: Duplicate Detection -- [ ] Phase 4: Semantic Search -- [ ] Phase 5: CLI Interface -- [ ] Phase 6: Voice Interface -- [ ] Phase 7: Advanced AI Features -- [ ] Phase 8: Production Polish - ---- - -**Version**: 1.0.0 (Phase 1) -**Last Updated**: 2024 diff --git a/inventory-system/backend/Dockerfile b/inventory-system/backend/Dockerfile deleted file mode 100644 index 5a8b491..0000000 --- a/inventory-system/backend/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -# Copy requirements first for better caching -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code -COPY . . - -# Expose port -EXPOSE 5000 - -CMD ["python", "run.py"] diff --git a/inventory-system/backend/app/__init__.py b/inventory-system/backend/app/__init__.py deleted file mode 100644 index 34a5886..0000000 --- a/inventory-system/backend/app/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -import os -from flask import Flask -from flask_migrate import Migrate -from app.models import db - - -def create_app(): - app = Flask(__name__, - template_folder='../frontend/templates', - static_folder='../frontend/static') - - # Configuration - app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv( - 'DATABASE_URL', - 'postgresql://inventoryuser:inventorypass@localhost:5432/inventory' - ) - app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') - - # Initialize extensions - db.init_app(app) - migrate = Migrate(app, db) - - # Register blueprints - from app.routes import main, items, locations, modules, search - app.register_blueprint(main.bp) - app.register_blueprint(items.bp, url_prefix='/items') - app.register_blueprint(locations.bp, url_prefix='/locations') - app.register_blueprint(modules.bp, url_prefix='/modules') - app.register_blueprint(search.bp, url_prefix='/search') - - # Create tables - with app.app_context(): - db.create_all() - - return app diff --git a/inventory-system/backend/app/models.py b/inventory-system/backend/app/models.py deleted file mode 100644 index ab5036b..0000000 --- a/inventory-system/backend/app/models.py +++ /dev/null @@ -1,212 +0,0 @@ -from datetime import datetime -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import JSON - -db = SQLAlchemy() - - -class Module(db.Model): - """Storage modules - the big units (cabinets, shelving units, etc.)""" - __tablename__ = 'modules' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - location_description = db.Column(db.String(200)) # Physical location in lab/shop - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationships - levels = db.relationship('Level', back_populates='module', cascade='all, delete-orphan') - - def __repr__(self): - return f'' - - def to_dict(self): - return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'location_description': self.location_description, - 'level_count': len(self.levels), - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - -class Level(db.Model): - """Levels within modules - drawers, shelves, compartments""" - __tablename__ = 'levels' - - id = db.Column(db.Integer, primary_key=True) - module_id = db.Column(db.Integer, db.ForeignKey('modules.id'), nullable=False) - level_number = db.Column(db.Integer, nullable=False) # 1, 2, 3, etc. - name = db.Column(db.String(100)) # Optional custom name - rows = db.Column(db.Integer, default=1) # Number of rows in grid - columns = db.Column(db.Integer, default=1) # Number of columns in grid - description = db.Column(db.Text) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationships - module = db.relationship('Module', back_populates='levels') - locations = db.relationship('Location', back_populates='level', cascade='all, delete-orphan') - - # Unique constraint: one level number per module - __table_args__ = ( - db.UniqueConstraint('module_id', 'level_number', name='unique_module_level'), - ) - - def __repr__(self): - return f'' - - def to_dict(self): - return { - 'id': self.id, - 'module_id': self.module_id, - 'module_name': self.module.name if self.module else None, - 'level_number': self.level_number, - 'name': self.name, - 'rows': self.rows, - 'columns': self.columns, - 'description': self.description, - 'location_count': len(self.locations), - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - -class Location(db.Model): - """Individual storage locations (bins) within levels""" - __tablename__ = 'locations' - - id = db.Column(db.Integer, primary_key=True) - level_id = db.Column(db.Integer, db.ForeignKey('levels.id'), nullable=False) - row = db.Column(db.String(10), nullable=False) # A, B, C or 1, 2, 3 - column = db.Column(db.String(10), nullable=False) # 1, 2, 3 or A, B, C - - # Location characteristics - location_type = db.Column(db.String(50), default='general') # small_box, medium_bin, large_bin, liquid_container, etc. - width_mm = db.Column(db.Float) - height_mm = db.Column(db.Float) - depth_mm = db.Column(db.Float) - notes = db.Column(db.Text) - - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationships - level = db.relationship('Level', back_populates='locations') - item_locations = db.relationship('ItemLocation', back_populates='location', cascade='all, delete-orphan') - - # Unique constraint: one location per row/col in a level - __table_args__ = ( - db.UniqueConstraint('level_id', 'row', 'column', name='unique_level_position'), - ) - - def __repr__(self): - return f'' - - def full_address(self): - """Returns full address like 'Zeus:3:B4'""" - if self.level and self.level.module: - return f"{self.level.module.name}:{self.level.level_number}:{self.row}{self.column}" - return f"{self.row}{self.column}" - - def to_dict(self): - return { - 'id': self.id, - 'level_id': self.level_id, - 'module_name': self.level.module.name if self.level and self.level.module else None, - 'level_number': self.level.level_number if self.level else None, - 'row': self.row, - 'column': self.column, - 'full_address': self.full_address(), - 'location_type': self.location_type, - 'dimensions': { - 'width_mm': self.width_mm, - 'height_mm': self.height_mm, - 'depth_mm': self.depth_mm, - } if self.width_mm or self.height_mm or self.depth_mm else None, - 'notes': self.notes, - 'item_count': len(self.item_locations), - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - -class Item(db.Model): - """Inventory items""" - __tablename__ = 'items' - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(200), nullable=False) - description = db.Column(db.Text, nullable=False) # Natural language description - category = db.Column(db.String(100)) # electronics, fasteners, tools, paints, etc. - - # Structured metadata (parsed from description or manually entered) - item_metadata = db.Column(JSON) # Flexible storage for specs, dimensions, etc. - - # Item characteristics - item_type = db.Column(db.String(50)) # solid, liquid, smd_component, bulk, etc. - notes = db.Column(db.Text) - tags = db.Column(db.String(500)) # Comma-separated tags - - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationships - item_locations = db.relationship('ItemLocation', back_populates='item', cascade='all, delete-orphan') - - def __repr__(self): - return f'' - - def to_dict(self): - return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'category': self.category, - 'item_metadata': self.item_metadata, - 'item_type': self.item_type, - 'notes': self.notes, - 'tags': self.tags.split(',') if self.tags else [], - 'locations': [il.to_dict() for il in self.item_locations], - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } - - -class ItemLocation(db.Model): - """Many-to-many relationship between items and locations""" - __tablename__ = 'item_locations' - - id = db.Column(db.Integer, primary_key=True) - item_id = db.Column(db.Integer, db.ForeignKey('items.id'), nullable=False) - location_id = db.Column(db.Integer, db.ForeignKey('locations.id'), nullable=False) - notes = db.Column(db.Text) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # Relationships - item = db.relationship('Item', back_populates='item_locations') - location = db.relationship('Location', back_populates='item_locations') - - # Unique constraint: one item per location - __table_args__ = ( - db.UniqueConstraint('item_id', 'location_id', name='unique_item_location'), - ) - - def __repr__(self): - return f'' - - def to_dict(self): - return { - 'id': self.id, - 'item_id': self.item_id, - 'location_id': self.location_id, - 'notes': self.notes, - 'location': self.location.to_dict() if self.location else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - } diff --git a/inventory-system/backend/app/routes/items.py b/inventory-system/backend/app/routes/items.py deleted file mode 100644 index 4a7401e..0000000 --- a/inventory-system/backend/app/routes/items.py +++ /dev/null @@ -1,213 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify -from app.models import db, Item, ItemLocation, Location, Level, Module - -bp = Blueprint('items', __name__) - - -@bp.route('/') -def list_items(): - """List all items""" - # Get filter parameters - category = request.args.get('category') - search = request.args.get('search') - - query = Item.query - - if category: - query = query.filter(Item.category == category) - - if search: - search_term = f"%{search}%" - query = query.filter( - db.or_( - Item.name.ilike(search_term), - Item.description.ilike(search_term), - Item.tags.ilike(search_term) - ) - ) - - items = query.order_by(Item.name).all() - - # Get unique categories for filter dropdown - categories = db.session.query(Item.category).distinct().all() - categories = [c[0] for c in categories if c[0]] - - return render_template('items/list.html', items=items, categories=categories) - - -@bp.route('/new', methods=['GET', 'POST']) -def new_item(): - """Create a new item""" - if request.method == 'POST': - name = request.form.get('name') - description = request.form.get('description') - category = request.form.get('category') - item_type = request.form.get('item_type') - tags = request.form.get('tags') - notes = request.form.get('notes') - - # Location information - location_id = request.form.get('location_id', type=int) - - if not name or not description: - flash('Name and description are required', 'error') - return render_template('items/form.html', modules=Module.query.all()) - - item = Item( - name=name, - description=description, - category=category, - item_type=item_type, - tags=tags, - notes=notes - ) - - db.session.add(item) - db.session.flush() # Get the item ID - - # Add location if provided - if location_id: - item_location = ItemLocation( - item_id=item.id, - location_id=location_id - ) - db.session.add(item_location) - - db.session.commit() - - flash(f'Item "{name}" created successfully', 'success') - return redirect(url_for('items.view_item', item_id=item.id)) - - # For GET request, load modules for location selection - modules = Module.query.order_by(Module.name).all() - return render_template('items/form.html', modules=modules) - - -@bp.route('/') -def view_item(item_id): - """View item details""" - item = Item.query.get_or_404(item_id) - modules = Module.query.order_by(Module.name).all() - return render_template('items/view.html', item=item, modules=modules) - - -@bp.route('//edit', methods=['GET', 'POST']) -def edit_item(item_id): - """Edit an item""" - item = Item.query.get_or_404(item_id) - - if request.method == 'POST': - item.name = request.form.get('name') - item.description = request.form.get('description') - item.category = request.form.get('category') - item.item_type = request.form.get('item_type') - item.tags = request.form.get('tags') - item.notes = request.form.get('notes') - - if not item.name or not item.description: - flash('Name and description are required', 'error') - return render_template('items/form.html', item=item, modules=Module.query.all()) - - db.session.commit() - - flash(f'Item "{item.name}" updated successfully', 'success') - return redirect(url_for('items.view_item', item_id=item.id)) - - modules = Module.query.order_by(Module.name).all() - return render_template('items/form.html', item=item, modules=modules) - - -@bp.route('//delete', methods=['POST']) -def delete_item(item_id): - """Delete an item""" - item = Item.query.get_or_404(item_id) - name = item.name - - db.session.delete(item) - db.session.commit() - - flash(f'Item "{name}" deleted successfully', 'success') - return redirect(url_for('items.list_items')) - - -@bp.route('//locations/add', methods=['POST']) -def add_location(item_id): - """Add a location to an item""" - item = Item.query.get_or_404(item_id) - - location_id = request.form.get('location_id', type=int) - notes = request.form.get('notes') - - if not location_id: - flash('Location is required', 'error') - return redirect(url_for('items.view_item', item_id=item_id)) - - # Check if this item-location combination already exists - existing = ItemLocation.query.filter_by(item_id=item_id, location_id=location_id).first() - if existing: - flash('Item is already stored at this location', 'error') - return redirect(url_for('items.view_item', item_id=item_id)) - - item_location = ItemLocation( - item_id=item_id, - location_id=location_id, - notes=notes - ) - - db.session.add(item_location) - db.session.commit() - - location = Location.query.get(location_id) - flash(f'Location {location.full_address()} added successfully', 'success') - return redirect(url_for('items.view_item', item_id=item_id)) - - -@bp.route('//locations//remove', methods=['POST']) -def remove_location(item_id, item_location_id): - """Remove a location from an item""" - item_location = ItemLocation.query.get_or_404(item_location_id) - - if item_location.item_id != item_id: - flash('Invalid item-location combination', 'error') - return redirect(url_for('items.view_item', item_id=item_id)) - - location_address = item_location.location.full_address() - - db.session.delete(item_location) - db.session.commit() - - flash(f'Location {location_address} removed successfully', 'success') - return redirect(url_for('items.view_item', item_id=item_id)) - - -# API endpoints - -@bp.route('/api/items', methods=['GET']) -def api_list_items(): - """API endpoint to list items""" - search = request.args.get('search') - category = request.args.get('category') - - query = Item.query - - if search: - search_term = f"%{search}%" - query = query.filter( - db.or_( - Item.name.ilike(search_term), - Item.description.ilike(search_term) - ) - ) - - if category: - query = query.filter(Item.category == category) - - items = query.order_by(Item.name).limit(50).all() - return jsonify([i.to_dict() for i in items]) - - -@bp.route('/api/items/', methods=['GET']) -def api_get_item(item_id): - """API endpoint to get a single item""" - item = Item.query.get_or_404(item_id) - return jsonify(item.to_dict()) diff --git a/inventory-system/backend/app/routes/locations.py b/inventory-system/backend/app/routes/locations.py deleted file mode 100644 index 00cfccb..0000000 --- a/inventory-system/backend/app/routes/locations.py +++ /dev/null @@ -1,101 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify -from app.models import db, Location, Level, Item, ItemLocation - -bp = Blueprint('locations', __name__) - - -@bp.route('/') -def list_locations(): - """List all locations""" - # Get filter parameters - module_id = request.args.get('module_id', type=int) - level_id = request.args.get('level_id', type=int) - location_type = request.args.get('location_type') - occupied = request.args.get('occupied') # 'yes', 'no', or None - - query = Location.query.join(Level) - - if level_id: - query = query.filter(Location.level_id == level_id) - elif module_id: - query = query.filter(Level.module_id == module_id) - - if location_type: - query = query.filter(Location.location_type == location_type) - - if occupied == 'yes': - query = query.join(ItemLocation).distinct() - elif occupied == 'no': - query = query.outerjoin(ItemLocation).filter(ItemLocation.id == None) - - locations = query.order_by(Level.module_id, Level.level_number, Location.row, Location.column).all() - - # Get unique location types for filter dropdown - location_types = db.session.query(Location.location_type).distinct().all() - location_types = [lt[0] for lt in location_types if lt[0]] - - return render_template('locations/list.html', - locations=locations, - location_types=location_types) - - -@bp.route('/') -def view_location(location_id): - """View location details and items stored there""" - location = Location.query.get_or_404(location_id) - item_locations = ItemLocation.query.filter_by(location_id=location_id).all() - - return render_template('locations/view.html', - location=location, - item_locations=item_locations) - - -@bp.route('//edit', methods=['GET', 'POST']) -def edit_location(location_id): - """Edit location properties""" - location = Location.query.get_or_404(location_id) - - if request.method == 'POST': - location.location_type = request.form.get('location_type', 'general') - location.width_mm = request.form.get('width_mm', type=float) - location.height_mm = request.form.get('height_mm', type=float) - location.depth_mm = request.form.get('depth_mm', type=float) - location.notes = request.form.get('notes') - - db.session.commit() - - flash(f'Location {location.full_address()} updated successfully', 'success') - return redirect(url_for('locations.view_location', location_id=location.id)) - - return render_template('locations/form.html', location=location) - - -# API endpoints - -@bp.route('/api/locations', methods=['GET']) -def api_list_locations(): - """API endpoint to list locations with filters""" - level_id = request.args.get('level_id', type=int) - location_type = request.args.get('location_type') - available = request.args.get('available', type=bool) - - query = Location.query - - if level_id: - query = query.filter(Location.level_id == level_id) - - if location_type: - query = query.filter(Location.location_type == location_type) - - if available: - query = query.outerjoin(ItemLocation).filter(ItemLocation.id == None) - - locations = query.all() - return jsonify([l.to_dict() for l in locations]) - - -@bp.route('/api/locations/', methods=['GET']) -def api_get_location(location_id): - """API endpoint to get a single location""" - location = Location.query.get_or_404(location_id) - return jsonify(location.to_dict()) diff --git a/inventory-system/backend/app/routes/main.py b/inventory-system/backend/app/routes/main.py deleted file mode 100644 index 555441c..0000000 --- a/inventory-system/backend/app/routes/main.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import Blueprint, render_template -from app.models import Module, Level, Location, Item - -bp = Blueprint('main', __name__) - - -@bp.route('/') -def index(): - """Main dashboard""" - stats = { - 'modules': Module.query.count(), - 'levels': Level.query.count(), - 'locations': Location.query.count(), - 'items': Item.query.count(), - } - - recent_items = Item.query.order_by(Item.created_at.desc()).limit(10).all() - modules = Module.query.order_by(Module.name).all() - - return render_template('index.html', stats=stats, recent_items=recent_items, modules=modules) - - -@bp.route('/about') -def about(): - """About page""" - return render_template('about.html') diff --git a/inventory-system/backend/app/routes/modules.py b/inventory-system/backend/app/routes/modules.py deleted file mode 100644 index 28b5f93..0000000 --- a/inventory-system/backend/app/routes/modules.py +++ /dev/null @@ -1,275 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify -from app.models import db, Module, Level, Location - -bp = Blueprint('modules', __name__) - - -@bp.route('/') -def list_modules(): - """List all modules""" - modules = Module.query.order_by(Module.name).all() - return render_template('modules/list.html', modules=modules) - - -@bp.route('/new', methods=['GET', 'POST']) -def new_module(): - """Create a new module""" - if request.method == 'POST': - name = request.form.get('name') - description = request.form.get('description') - location_description = request.form.get('location_description') - - if not name: - flash('Name is required', 'error') - return render_template('modules/form.html') - - # Check if name already exists - existing = Module.query.filter_by(name=name).first() - if existing: - flash(f'Module with name "{name}" already exists', 'error') - return render_template('modules/form.html') - - module = Module( - name=name, - description=description, - location_description=location_description - ) - - db.session.add(module) - db.session.commit() - - flash(f'Module "{name}" created successfully', 'success') - return redirect(url_for('modules.view_module', module_id=module.id)) - - return render_template('modules/form.html') - - -@bp.route('/') -def view_module(module_id): - """View module details and its levels""" - module = Module.query.get_or_404(module_id) - levels = Level.query.filter_by(module_id=module_id).order_by(Level.level_number).all() - return render_template('modules/view.html', module=module, levels=levels) - - -@bp.route('//edit', methods=['GET', 'POST']) -def edit_module(module_id): - """Edit a module""" - module = Module.query.get_or_404(module_id) - - if request.method == 'POST': - name = request.form.get('name') - description = request.form.get('description') - location_description = request.form.get('location_description') - - if not name: - flash('Name is required', 'error') - return render_template('modules/form.html', module=module) - - # Check if name already exists (excluding current module) - existing = Module.query.filter(Module.name == name, Module.id != module_id).first() - if existing: - flash(f'Module with name "{name}" already exists', 'error') - return render_template('modules/form.html', module=module) - - module.name = name - module.description = description - module.location_description = location_description - - db.session.commit() - - flash(f'Module "{name}" updated successfully', 'success') - return redirect(url_for('modules.view_module', module_id=module.id)) - - return render_template('modules/form.html', module=module) - - -@bp.route('//delete', methods=['POST']) -def delete_module(module_id): - """Delete a module""" - module = Module.query.get_or_404(module_id) - name = module.name - - db.session.delete(module) - db.session.commit() - - flash(f'Module "{name}" deleted successfully', 'success') - return redirect(url_for('modules.list_modules')) - - -@bp.route('//levels/new', methods=['GET', 'POST']) -def new_level(module_id): - """Add a new level to a module""" - module = Module.query.get_or_404(module_id) - - if request.method == 'POST': - level_number = request.form.get('level_number', type=int) - name = request.form.get('name') - rows = request.form.get('rows', type=int, default=1) - columns = request.form.get('columns', type=int, default=1) - description = request.form.get('description') - - if not level_number: - flash('Level number is required', 'error') - return render_template('levels/form.html', module=module) - - # Check if level number already exists for this module - existing = Level.query.filter_by(module_id=module_id, level_number=level_number).first() - if existing: - flash(f'Level {level_number} already exists in this module', 'error') - return render_template('levels/form.html', module=module) - - level = Level( - module_id=module_id, - level_number=level_number, - name=name, - rows=rows, - columns=columns, - description=description - ) - - db.session.add(level) - db.session.commit() - - # Create locations for this level - create_locations_for_level(level) - - flash(f'Level {level_number} created with {rows}x{columns} locations', 'success') - return redirect(url_for('modules.view_module', module_id=module_id)) - - return render_template('levels/form.html', module=module) - - -@bp.route('/levels/') -def view_level(level_id): - """View level details with its locations""" - level = Level.query.get_or_404(level_id) - - # Organize locations by row and column - locations = Location.query.filter_by(level_id=level_id).all() - location_grid = {} - for loc in locations: - if loc.row not in location_grid: - location_grid[loc.row] = {} - location_grid[loc.row][loc.column] = loc - - return render_template('levels/view.html', level=level, location_grid=location_grid, chr=chr) - - -@bp.route('/levels//edit', methods=['GET', 'POST']) -def edit_level(level_id): - """Edit a level""" - level = Level.query.get_or_404(level_id) - module = level.module - - if request.method == 'POST': - level_number = request.form.get('level_number', type=int) - name = request.form.get('name') - rows = request.form.get('rows', type=int, default=1) - columns = request.form.get('columns', type=int, default=1) - description = request.form.get('description') - - if not level_number: - flash('Level number is required', 'error') - return render_template('levels/form.html', module=module, level=level) - - # Check if level number already exists (excluding current level) - existing = Level.query.filter( - Level.module_id == level.module_id, - Level.level_number == level_number, - Level.id != level_id - ).first() - if existing: - flash(f'Level {level_number} already exists in this module', 'error') - return render_template('levels/form.html', module=module, level=level) - - # Check if grid size changed - old_rows = level.rows - old_columns = level.columns - - level.level_number = level_number - level.name = name - level.rows = rows - level.columns = columns - level.description = description - - db.session.commit() - - # If grid size changed, recreate locations - if old_rows != rows or old_columns != columns: - # Delete old locations (cascade will handle item_locations) - Location.query.filter_by(level_id=level_id).delete() - db.session.commit() - - # Create new locations - create_locations_for_level(level) - flash(f'Level updated and locations regenerated ({rows}x{columns})', 'success') - else: - flash(f'Level {level_number} updated successfully', 'success') - - return redirect(url_for('modules.view_level', level_id=level.id)) - - return render_template('levels/form.html', module=module, level=level) - - -@bp.route('/levels//delete', methods=['POST']) -def delete_level(level_id): - """Delete a level""" - level = Level.query.get_or_404(level_id) - module_id = level.module_id - level_num = level.level_number - - db.session.delete(level) - db.session.commit() - - flash(f'Level {level_num} deleted successfully', 'success') - return redirect(url_for('modules.view_module', module_id=module_id)) - - -def create_locations_for_level(level): - """Helper function to create locations for a level based on its grid""" - rows = level.rows - columns = level.columns - - # Generate row labels (A, B, C... or 1, 2, 3...) - row_labels = [chr(65 + i) for i in range(rows)] if rows <= 26 else [str(i+1) for i in range(rows)] - - # Generate column labels (1, 2, 3...) - col_labels = [str(i+1) for i in range(columns)] - - locations = [] - for row_label in row_labels: - for col_label in col_labels: - location = Location( - level_id=level.id, - row=row_label, - column=col_label, - location_type='general' - ) - locations.append(location) - - db.session.add_all(locations) - db.session.commit() - - -# API endpoints for AJAX requests - -@bp.route('/api/modules', methods=['GET']) -def api_list_modules(): - """API endpoint to list all modules""" - modules = Module.query.order_by(Module.name).all() - return jsonify([m.to_dict() for m in modules]) - - -@bp.route('/api/modules/', methods=['GET']) -def api_get_module(module_id): - """API endpoint to get a single module""" - module = Module.query.get_or_404(module_id) - return jsonify(module.to_dict()) - - -@bp.route('/api/modules//levels', methods=['GET']) -def api_list_levels(module_id): - """API endpoint to list levels for a module""" - levels = Level.query.filter_by(module_id=module_id).order_by(Level.level_number).all() - return jsonify([l.to_dict() for l in levels]) diff --git a/inventory-system/backend/app/routes/search.py b/inventory-system/backend/app/routes/search.py deleted file mode 100644 index dc4e08a..0000000 --- a/inventory-system/backend/app/routes/search.py +++ /dev/null @@ -1,48 +0,0 @@ -from flask import Blueprint, render_template, request, jsonify -from app.models import db, Item - -bp = Blueprint('search', __name__) - - -@bp.route('/') -def search(): - """Search page""" - query = request.args.get('q', '') - results = [] - - if query: - search_term = f"%{query}%" - results = Item.query.filter( - db.or_( - Item.name.ilike(search_term), - Item.description.ilike(search_term), - Item.tags.ilike(search_term), - Item.notes.ilike(search_term) - ) - ).order_by(Item.name).all() - - return render_template('search/results.html', query=query, results=results) - - -@bp.route('/api', methods=['GET']) -def api_search(): - """API endpoint for search""" - query = request.args.get('q', '') - - if not query: - return jsonify({'results': []}) - - search_term = f"%{query}%" - items = Item.query.filter( - db.or_( - Item.name.ilike(search_term), - Item.description.ilike(search_term), - Item.tags.ilike(search_term) - ) - ).order_by(Item.name).limit(20).all() - - return jsonify({ - 'query': query, - 'count': len(items), - 'results': [i.to_dict() for i in items] - }) diff --git a/inventory-system/backend/requirements.txt b/inventory-system/backend/requirements.txt deleted file mode 100644 index 21394b0..0000000 --- a/inventory-system/backend/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -Flask==3.0.0 -Flask-SQLAlchemy==3.1.1 -Flask-Migrate==4.0.5 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 -sqlalchemy==2.0.23 diff --git a/inventory-system/backend/run.py b/inventory-system/backend/run.py deleted file mode 100644 index c962b68..0000000 --- a/inventory-system/backend/run.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -""" -Inventory System - Flask Application Runner -Phase 1: Foundation -""" - -import os -from app import create_app -from app.models import db - -app = create_app() - -if __name__ == '__main__': - with app.app_context(): - # Create all database tables - db.create_all() - print("Database tables created successfully!") - - # Run the application - port = int(os.getenv('PORT', 5000)) - debug = os.getenv('FLASK_ENV') == 'development' - - print(f"\n{'='*60}") - print("🏠 Homelab Inventory System - Phase 1: Foundation") - print(f"{'='*60}") - print(f"Starting Flask server on http://0.0.0.0:{port}") - print(f"Debug mode: {debug}") - print(f"{'='*60}\n") - - app.run(host='0.0.0.0', port=port, debug=debug) diff --git a/inventory-system/create_sample_data.py b/inventory-system/create_sample_data.py deleted file mode 100644 index 086afb2..0000000 --- a/inventory-system/create_sample_data.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to populate the inventory system with sample data -Run this after the system is deployed to see it in action -""" - -import requests -import time - -BASE_URL = "http://localhost:8080" - -def wait_for_server(): - """Wait for the server to be ready""" - print("Waiting for server to be ready...") - max_attempts = 30 - for i in range(max_attempts): - try: - response = requests.get(BASE_URL) - if response.status_code == 200: - print("✓ Server is ready!") - return True - except: - pass - time.sleep(1) - print("✗ Server did not start in time") - return False - -def create_sample_data(): - """Create sample modules, levels, and items""" - - print("\n" + "="*60) - print("Creating Sample Data for Inventory System") - print("="*60 + "\n") - - # Sample modules - modules = [ - { - "name": "Zeus", - "description": "Main electronics and component storage", - "location_description": "North wall, workshop" - }, - { - "name": "Muse", - "description": "Fasteners and hardware storage", - "location_description": "East wall, near workbench" - }, - { - "name": "Apollo", - "description": "Tools and equipment", - "location_description": "Tool wall, west side" - } - ] - - created_modules = [] - - # Create modules - print("Creating modules...") - for module_data in modules: - try: - response = requests.post(f"{BASE_URL}/modules/new", data=module_data, allow_redirects=False) - if response.status_code in [200, 302]: - print(f" ✓ Created module: {module_data['name']}") - created_modules.append(module_data['name']) - else: - print(f" ✗ Failed to create module: {module_data['name']}") - except Exception as e: - print(f" ✗ Error creating module {module_data['name']}: {e}") - - # Get module IDs - try: - response = requests.get(f"{BASE_URL}/modules/api/modules") - modules_list = response.json() - module_map = {m['name']: m['id'] for m in modules_list} - except: - print("✗ Failed to get module list") - return - - # Sample levels for each module - levels_data = { - "Zeus": [ - {"level_number": 1, "name": "Top Drawer", "rows": 4, "columns": 6, "description": "Small components"}, - {"level_number": 2, "name": "Middle Drawer", "rows": 3, "columns": 4, "description": "Medium bins"}, - {"level_number": 3, "name": "Bottom Drawer", "rows": 2, "columns": 3, "description": "Large storage"} - ], - "Muse": [ - {"level_number": 1, "rows": 5, "columns": 8, "description": "Metric fasteners"}, - {"level_number": 2, "rows": 5, "columns": 8, "description": "Imperial fasteners"}, - {"level_number": 3, "rows": 4, "columns": 6, "description": "Specialty hardware"} - ], - "Apollo": [ - {"level_number": 1, "rows": 2, "columns": 4, "description": "Hand tools"}, - {"level_number": 2, "rows": 2, "columns": 3, "description": "Power tools"} - ] - } - - print("\nCreating levels...") - for module_name, levels in levels_data.items(): - if module_name not in module_map: - continue - - module_id = module_map[module_name] - for level_data in levels: - try: - response = requests.post(f"{BASE_URL}/modules/{module_id}/levels/new", data=level_data, allow_redirects=False) - if response.status_code in [200, 302]: - print(f" ✓ Created {module_name} Level {level_data['level_number']}") - else: - print(f" ✗ Failed to create level {level_data['level_number']} in {module_name}") - except Exception as e: - print(f" ✗ Error creating level: {e}") - - # Sample items - items_data = [ - { - "name": "M6 Hex Bolts", - "description": "Hex head bolt, M6 diameter, 50mm long, zinc plated, metric thread", - "category": "Fasteners", - "item_type": "solid", - "quantity": 100, - "unit": "pieces", - "tags": "bolt, metric, m6, hex, zinc, fastener" - }, - { - "name": "1kΩ Resistors", - "description": "1/4 watt carbon film resistor, 1000 ohm, 5% tolerance, through-hole", - "category": "Electronics", - "item_type": "solid", - "quantity": 200, - "unit": "pieces", - "tags": "resistor, 1k, 1000ohm, carbon film, electronics" - }, - { - "name": "Arduino Uno R3", - "description": "Arduino Uno R3 development board, ATmega328P microcontroller, USB interface", - "category": "Electronics", - "item_type": "solid", - "quantity": 5, - "unit": "pieces", - "tags": "arduino, uno, microcontroller, development board" - }, - { - "name": "#8 Wood Screws", - "description": "Phillips pan head wood screw, #8 size, 3/4 inch long, zinc plated", - "category": "Fasteners", - "item_type": "solid", - "quantity": 250, - "unit": "pieces", - "tags": "screw, wood screw, phillips, pan head, #8" - }, - { - "name": "SMD Capacitors 0.1µF", - "description": "Ceramic capacitor, 0.1 microfarad, 0805 package, 50V rating", - "category": "Electronics", - "item_type": "smd_component", - "quantity": 500, - "unit": "pieces", - "tags": "capacitor, smd, 0805, ceramic, 0.1uf" - }, - { - "name": "Red Spray Paint", - "description": "Rust-Oleum 2X Ultra Cover Paint+Primer, gloss red, 12 oz aerosol", - "category": "Paints & Coatings", - "item_type": "liquid", - "quantity": 3, - "unit": "cans", - "tags": "paint, spray paint, red, rust-oleum" - }, - { - "name": "Phillips Screwdriver", - "description": "Phillips head screwdriver, #2 size, 6 inch shaft, cushion grip handle", - "category": "Tools", - "item_type": "tool", - "quantity": 2, - "unit": "pieces", - "tags": "screwdriver, phillips, hand tool" - }, - { - "name": "M3 Standoffs", - "description": "Aluminum standoff, M3 thread, 10mm length, hex body", - "category": "Hardware", - "item_type": "solid", - "quantity": 50, - "unit": "pieces", - "tags": "standoff, m3, metric, aluminum, spacer" - }, - { - "name": "LED 5mm Red", - "description": "Light emitting diode, 5mm diameter, red, 2V forward voltage, 20mA", - "category": "Electronics", - "item_type": "solid", - "quantity": 100, - "unit": "pieces", - "tags": "led, red, 5mm, light, electronics" - }, - { - "name": "Zip Ties 6 inch", - "description": "Nylon cable tie, 6 inch length, 40 lb tensile strength, black", - "category": "Hardware", - "item_type": "bulk", - "quantity": 500, - "unit": "pieces", - "tags": "zip tie, cable tie, nylon, fastener" - } - ] - - print("\nCreating items...") - for item_data in items_data: - try: - response = requests.post(f"{BASE_URL}/items/new", data=item_data, allow_redirects=False) - if response.status_code in [200, 302]: - print(f" ✓ Created item: {item_data['name']}") - else: - print(f" ✗ Failed to create item: {item_data['name']}") - except Exception as e: - print(f" ✗ Error creating item {item_data['name']}: {e}") - - print("\n" + "="*60) - print("Sample data creation complete!") - print("="*60) - print(f"\nYou can now access the system at: {BASE_URL}") - print("\nTry these features:") - print(" • Browse Modules to see the storage hierarchy") - print(" • View Items to see the inventory") - print(" • Use Search to find items by keyword") - print(" • Click on levels to see the location grid") - print("\n") - -if __name__ == "__main__": - print("\n🏠 Homelab Inventory System - Sample Data Generator\n") - - if wait_for_server(): - time.sleep(2) # Give it a moment to fully initialize - create_sample_data() - else: - print("\nMake sure the system is running:") - print(" docker-compose up -d") - print("\nThen run this script again.") diff --git a/inventory-system/docker-compose.yml b/inventory-system/docker-compose.yml deleted file mode 100644 index 8d9689d..0000000 --- a/inventory-system/docker-compose.yml +++ /dev/null @@ -1,46 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - container_name: inventory-db - environment: - POSTGRES_DB: inventory - POSTGRES_USER: inventoryuser - POSTGRES_PASSWORD: inventorypass - volumes: - - ./data/postgres:/var/lib/postgresql/data - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U inventoryuser -d inventory"] - interval: 10s - timeout: 5s - retries: 5 - - backend: - build: ./backend - container_name: inventory-backend - environment: - DATABASE_URL: postgresql://inventoryuser:inventorypass@postgres:5432/inventory - FLASK_ENV: development - FLASK_APP: app - volumes: - - ./backend:/app - - ./frontend:/app/frontend - ports: - - "5000:5000" - depends_on: - postgres: - condition: service_healthy - command: python run.py - - nginx: - image: nginx:alpine - container_name: inventory-nginx - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - ports: - - "8080:80" - depends_on: - - backend diff --git a/inventory-system/docs/ARCHITECTURE.md b/inventory-system/docs/ARCHITECTURE.md deleted file mode 100644 index 1ede231..0000000 --- a/inventory-system/docs/ARCHITECTURE.md +++ /dev/null @@ -1,578 +0,0 @@ -# Technical Architecture - Phase 1 - -## System Overview - -The Homelab Inventory System is a full-stack web application designed for managing thousands of inventory items across organized storage locations. - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────┐ -│ Web Browser │ -│ (User Interface) │ -└────────────────────┬────────────────────────────────────┘ - │ HTTP/HTTPS - │ Port 8080 -┌────────────────────▼────────────────────────────────────┐ -│ NGINX │ -│ (Reverse Proxy) │ -└────────────────────┬────────────────────────────────────┘ - │ - │ Proxy Pass - │ Port 5000 -┌────────────────────▼────────────────────────────────────┐ -│ Flask Application │ -│ (Python Backend) │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Routes Layer │ │ -│ │ - main.py (Dashboard) │ │ -│ │ - items.py (Item CRUD) │ │ -│ │ - modules.py (Storage CRUD) │ │ -│ │ - locations.py (Location management) │ │ -│ │ - search.py (Search functionality) │ │ -│ └──────────────────┬──────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────▼──────────────────────────────┐ │ -│ │ SQLAlchemy ORM │ │ -│ │ - models.py (Database models) │ │ -│ │ - Relationships & constraints │ │ -│ └──────────────────┬──────────────────────────────┘ │ -└────────────────────┬┴───────────────────────────────────┘ - │ - │ TCP/IP - │ Port 5432 -┌────────────────────▼────────────────────────────────────┐ -│ PostgreSQL Database │ -│ │ -│ Tables: │ -│ - modules │ -│ - levels │ -│ - locations │ -│ - items │ -│ - item_locations (junction table) │ -│ │ -│ Persistent Volume: ./data/postgres │ -└─────────────────────────────────────────────────────────┘ -``` - -## Technology Stack - -### Frontend -- **HTML5**: Semantic markup -- **CSS3**: Custom styling with CSS variables -- **JavaScript (ES6+)**: Client-side interactivity -- **Jinja2**: Server-side templating - -### Backend -- **Python 3.11+**: Programming language -- **Flask 3.0**: Web framework -- **SQLAlchemy 2.0**: ORM -- **Flask-Migrate 4.0**: Database migrations -- **psycopg2**: PostgreSQL adapter - -### Database -- **PostgreSQL 15**: Primary data store -- **Relations**: Foreign keys with cascade -- **Constraints**: Unique, not null, check constraints -- **JSON fields**: For flexible metadata storage - -### Infrastructure -- **Docker**: Containerization -- **Docker Compose**: Multi-container orchestration -- **nginx**: Reverse proxy and static file serving -- **Ubuntu 24**: Base OS for containers - -## Database Schema - -### Tables - -#### modules -```sql -CREATE TABLE modules ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) UNIQUE NOT NULL, - description TEXT, - location_description VARCHAR(200), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -#### levels -```sql -CREATE TABLE levels ( - id SERIAL PRIMARY KEY, - module_id INTEGER REFERENCES modules(id) ON DELETE CASCADE, - level_number INTEGER NOT NULL, - name VARCHAR(100), - rows INTEGER DEFAULT 1, - columns INTEGER DEFAULT 1, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(module_id, level_number) -); -``` - -#### locations -```sql -CREATE TABLE locations ( - id SERIAL PRIMARY KEY, - level_id INTEGER REFERENCES levels(id) ON DELETE CASCADE, - row VARCHAR(10) NOT NULL, - column VARCHAR(10) NOT NULL, - location_type VARCHAR(50) DEFAULT 'general', - width_mm FLOAT, - height_mm FLOAT, - depth_mm FLOAT, - notes TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(level_id, row, column) -); -``` - -#### items -```sql -CREATE TABLE items ( - id SERIAL PRIMARY KEY, - name VARCHAR(200) NOT NULL, - description TEXT NOT NULL, - category VARCHAR(100), - metadata JSON, - quantity INTEGER DEFAULT 1, - unit VARCHAR(20), - min_quantity INTEGER, - item_type VARCHAR(50), - notes TEXT, - tags VARCHAR(500), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -#### item_locations -```sql -CREATE TABLE item_locations ( - id SERIAL PRIMARY KEY, - item_id INTEGER REFERENCES items(id) ON DELETE CASCADE, - location_id INTEGER REFERENCES locations(id) ON DELETE CASCADE, - quantity INTEGER DEFAULT 1, - notes TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(item_id, location_id) -); -``` - -### Relationships - -``` -modules (1) ──< (N) levels -levels (1) ──< (N) locations -items (N) >──< (N) locations [via item_locations] -``` - -## API Endpoints - -### Web UI Routes - -#### Dashboard -- `GET /` - Main dashboard - -#### Modules -- `GET /modules/` - List modules -- `GET /modules/new` - Module creation form -- `POST /modules/new` - Create module -- `GET /modules/` - View module -- `GET /modules//edit` - Edit form -- `POST /modules//edit` - Update module -- `POST /modules//delete` - Delete module - -#### Levels -- `GET /modules//levels/new` - Level creation form -- `POST /modules//levels/new` - Create level -- `GET /modules/levels/` - View level -- `GET /modules/levels//edit` - Edit form -- `POST /modules/levels//edit` - Update level -- `POST /modules/levels//delete` - Delete level - -#### Items -- `GET /items/` - List items -- `GET /items/new` - Item creation form -- `POST /items/new` - Create item -- `GET /items/` - View item -- `GET /items//edit` - Edit form -- `POST /items//edit` - Update item -- `POST /items//delete` - Delete item -- `POST /items//locations/add` - Add location -- `POST /items//locations//remove` - Remove location - -#### Locations -- `GET /locations/` - List locations (with filters) -- `GET /locations/` - View location -- `GET /locations//edit` - Edit form -- `POST /locations//edit` - Update location - -#### Search -- `GET /search/?q=` - Search page - -### REST API Routes - -#### Modules -- `GET /modules/api/modules` - List all modules (JSON) -- `GET /modules/api/modules/` - Get module (JSON) -- `GET /modules/api/modules//levels` - List levels (JSON) - -#### Locations -- `GET /locations/api/locations` - List locations (JSON) - - Query params: `level_id`, `location_type`, `available` -- `GET /locations/api/locations/` - Get location (JSON) - -#### Items -- `GET /items/api/items` - List items (JSON) - - Query params: `search`, `category` -- `GET /items/api/items/` - Get item (JSON) - -#### Search -- `GET /search/api?q=` - Search items (JSON) - -## Data Flow - -### Creating an Item with Location - -``` -1. User fills form - └─> POST /items/new - -2. Flask route handler (items.py) - ├─> Validate input - ├─> Create Item object - ├─> db.session.add(item) - ├─> db.session.flush() # Get item.id - ├─> Create ItemLocation object - ├─> db.session.add(item_location) - └─> db.session.commit() - -3. Database - ├─> INSERT INTO items - └─> INSERT INTO item_locations - -4. Redirect to item view page -``` - -### Searching for Items - -``` -1. User enters search query - └─> GET /search/?q=M6+bolt - -2. Flask route handler (search.py) - ├─> Extract query parameter - ├─> Build SQL ILIKE query - │ WHERE name ILIKE '%M6%bolt%' - │ OR description ILIKE '%M6%bolt%' - │ OR tags ILIKE '%M6%bolt%' - └─> Execute query via SQLAlchemy - -3. Database - └─> Return matching rows - -4. Render results template - └─> Show items with locations -``` - -### Viewing Location Grid - -``` -1. User clicks level - └─> GET /modules/levels/ - -2. Flask route handler (modules.py) - ├─> Query level by ID - ├─> Query all locations for level - ├─> Organize into grid structure - │ grid[row][column] = location - └─> Pass to template - -3. Template (levels/view.html) - ├─> Iterate rows - ├─> Iterate columns - ├─> Render table cell - │ ├─> Show location address - │ ├─> Show item count - │ └─> Apply CSS class (occupied/empty) - └─> Generate interactive grid - -4. Browser - └─> Render visual grid with colors -``` - -## Security Considerations - -### Phase 1 (Current) -- ✅ SQL injection protection (SQLAlchemy ORM) -- ✅ CSRF protection via Flask -- ✅ Input validation -- ⚠️ No authentication (single-user) -- ⚠️ No HTTPS (development only) -- ⚠️ Default database password - -### Future Phases -- 🔜 User authentication -- 🔜 Role-based access control -- 🔜 HTTPS/TLS -- 🔜 Session management -- 🔜 API rate limiting -- 🔜 Audit logging - -## Performance Characteristics - -### Current (Phase 1) -- **Database**: Single PostgreSQL instance -- **Queries**: Non-optimized, works well up to ~10k items -- **Indexing**: Primary keys only -- **Caching**: None -- **Concurrent users**: 1-5 recommended - -### Future Optimizations -- Database indexing on frequently queried fields -- Query optimization with eager loading -- Redis caching layer -- Connection pooling -- CDN for static assets - -## Deployment Configurations - -### Development -```yaml -services: - postgres: - # Development with data persistence - backend: - FLASK_ENV: development - # Auto-reload enabled - nginx: - # Basic proxy only -``` - -### Production (Recommended Changes) -```yaml -services: - postgres: - # Strong password - # Backup volumes - # Resource limits - backend: - FLASK_ENV: production - # Gunicorn/uWSGI - # Multiple workers - nginx: - # SSL/TLS certificates - # Gzip compression - # Rate limiting -``` - -## Monitoring & Logging - -### Current Logging -- Flask request logs (stdout) -- PostgreSQL logs (Docker logs) -- nginx access logs (Docker logs) - -### View Logs -```bash -docker-compose logs backend -docker-compose logs postgres -docker-compose logs nginx -``` - -### Future Monitoring -- Application performance monitoring (APM) -- Error tracking (Sentry) -- Metrics (Prometheus) -- Dashboards (Grafana) - -## Scalability Path - -### Current Limits -- Single server deployment -- ~10,000 items perform well -- ~1,000 locations per level max -- Single database instance - -### Future Scaling -**Horizontal:** -- Load balancer → Multiple Flask instances -- Read replicas for PostgreSQL -- Redis for session storage - -**Vertical:** -- More RAM for larger datasets -- SSD for database performance -- CPU for concurrent users - -## Extensibility Points - -### Adding Features -1. **New routes**: Add to `app/routes/` -2. **New models**: Extend `app/models.py` -3. **New templates**: Add to `frontend/templates/` -4. **New API endpoints**: Follow existing pattern - -### Plugin Architecture (Future) -- Custom location types -- Custom item attributes -- Export formats -- Integration hooks - -## Development Workflow - -### Adding a New Feature - -1. **Model changes** - ```python - # app/models.py - class NewTable(db.Model): - # Define schema - ``` - -2. **Route handler** - ```python - # app/routes/new_feature.py - @bp.route('/new') - def new_feature(): - # Logic here - ``` - -3. **Template** - ```html - - {% extends "base.html" %} - ``` - -4. **Register blueprint** - ```python - # app/__init__.py - app.register_blueprint(new_feature.bp) - ``` - -### Testing Changes -```bash -docker-compose restart backend -docker-compose logs -f backend -``` - -## Migration Path to Phase 2+ - -### Phase 2 Additions -- Location suggestion service -- Size/type constraint checking -- Visual location picker - -### Phase 3 Additions -- Duplicate detection service -- Fuzzy matching algorithm -- Similarity scoring - -### Phase 4 Additions -- Sentence transformer model -- Embedding generation service -- Vector similarity search -- pgvector extension - -### Phase 5 Additions -- CLI tool (`invctl`) -- Batch operations -- CSV import/export - -### Phase 6 Additions -- Voice service (Whisper/Vosk) -- Wake word detection (Porcupine) -- TTS service -- Audio interface - -## Technical Debt - -### Known Issues -- No database migrations setup (add Flask-Migrate migrations) -- No automated tests -- No CI/CD pipeline -- Limited error handling -- No rate limiting - -### Future Improvements -- Add pytest test suite -- Set up GitHub Actions -- Improve error messages -- Add request validation with marshmallow -- Implement caching strategy - -## Dependencies - -### Python Packages -``` -Flask==3.0.0 # Web framework -Flask-SQLAlchemy==3.1.1 # ORM -Flask-Migrate==4.0.5 # Migrations -psycopg2-binary==2.9.9 # PostgreSQL driver -python-dotenv==1.0.0 # Environment variables -sqlalchemy==2.0.23 # Database toolkit -``` - -### System Requirements -- Python 3.11+ -- PostgreSQL 15+ -- Docker 20.10+ -- Docker Compose 2.0+ - -## Configuration Files - -### docker-compose.yml -Defines all services and their relationships - -### nginx.conf -Reverse proxy configuration - -### .env -Environment variables (DATABASE_URL, SECRET_KEY, etc.) - -## File Structure -``` -inventory-system/ -├── backend/ -│ ├── app/ -│ │ ├── __init__.py # App factory -│ │ ├── models.py # Database models -│ │ ├── routes/ # Route handlers -│ │ │ ├── main.py -│ │ │ ├── items.py -│ │ │ ├── modules.py -│ │ │ ├── locations.py -│ │ │ └── search.py -│ │ └── services/ # Business logic (future) -│ ├── requirements.txt -│ ├── Dockerfile -│ └── run.py -├── frontend/ -│ ├── templates/ -│ │ ├── base.html -│ │ ├── index.html -│ │ └── [feature]/ -│ └── static/ -│ ├── css/style.css -│ └── js/main.js -├── data/ # Docker volumes -│ └── postgres/ -├── docker-compose.yml -├── nginx.conf -└── README.md -``` - ---- - -This architecture is designed to be: -- **Modular**: Easy to add features -- **Maintainable**: Clear separation of concerns -- **Scalable**: Can grow with your needs -- **Extensible**: Plugin-friendly design diff --git a/inventory-system/docs/DEPLOY.md b/inventory-system/docs/DEPLOY.md deleted file mode 100644 index 3d9929b..0000000 --- a/inventory-system/docs/DEPLOY.md +++ /dev/null @@ -1,548 +0,0 @@ -# 🚀 Homelab Inventory System - Complete Deployment Guide - -## What You Have - -A **complete Phase 1 Inventory System** ready to deploy! This is a working system you can start using immediately. - -### ✅ Included Features (Phase 1) - -- Full storage hierarchy (Modules → Levels → Locations) -- Web-based UI for managing inventory -- PostgreSQL database for reliable storage -- Docker deployment (runs anywhere) -- Basic search functionality -- Item tracking with quantities -- Location management with grid visualization -- Many-to-many item-location relationships - -### 🔜 Coming Next (Future Phases) - -- Phase 2: Smart location suggestions -- Phase 3: Duplicate detection -- Phase 4: AI semantic search -- Phase 5: CLI interface -- Phase 6: Voice interface -- Phase 7: Advanced AI features -- Phase 8: Production polish - ---- - -## Quick Start (5 Minutes) - -### Prerequisites - -- Docker & Docker Compose installed -- 2GB RAM available -- 10GB disk space -- Ports 8080, 5432, 5000 available - -### Deployment Steps - -```bash -# 1. Extract the inventory-system folder to your server/workstation -cd inventory-system - -# 2. Start the system -docker-compose up -d - -# 3. Wait 30-60 seconds for containers to start - -# 4. Open your browser -http://localhost:8080 - -# 5. (Optional) Load sample data -python3 create_sample_data.py -``` - -**That's it!** You now have a working inventory system. - ---- - -## Deployment Options - -### Option 1: Local Workstation/Server - -Perfect for testing and personal use. - -```bash -cd inventory-system -docker-compose up -d -``` - -Access at: `http://localhost:8080` - -### Option 2: VPS (Cloud Server) - -Deploy to DigitalOcean, Linode, AWS, etc. - -```bash -# SSH into your VPS -ssh user@your-vps-ip - -# Install Docker & Docker Compose (if not installed) -curl -fsSL https://get.docker.com | sh -sudo usermod -aG docker $USER -sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose -sudo chmod +x /usr/local/bin/docker-compose - -# Deploy system -cd inventory-system -docker-compose up -d - -# Configure firewall (if needed) -sudo ufw allow 8080/tcp -``` - -Access at: `http://your-vps-ip:8080` - -### Option 3: Proxmox LXC Container - -Great for homelab deployments. - -```bash -# 1. Create Ubuntu 22.04 LXC container in Proxmox -# 2. Install Docker in the container -apt update && apt install -y docker.io docker-compose - -# 3. Copy inventory-system folder to container -# 4. Deploy -cd inventory-system -docker-compose up -d -``` - -Access at: `http://container-ip:8080` - -### Option 4: Jetson Nano (Edge AI Device) - -For local AI inference capabilities (Phase 4+). - -```bash -# Docker should already be installed on Jetson -cd inventory-system -docker-compose up -d -``` - -Access at: `http://jetson-ip:8080` - ---- - -## First-Time Setup - -### 1. Create Your Storage Modules - -Modules are your physical storage units (cabinets, shelving, drawers). - -**Example Modules:** -- `Zeus` - Electronics cabinet -- `Muse` - Fasteners organizer -- `Apollo` - Tool chest -- `Workshop-Main` - Main workbench storage - -### 2. Add Levels to Modules - -Levels are drawers, shelves, or compartments within modules. - -**Example for "Zeus" module:** -- Level 1: Top drawer (4 rows × 6 columns) = 24 bins -- Level 2: Middle drawer (3 rows × 4 columns) = 12 bins -- Level 3: Bottom drawer (2 rows × 3 columns) = 6 bins - -The system automatically creates locations (A1, A2, B1, B2, etc.) based on your grid. - -### 3. Add Items - -Items are your actual inventory pieces. - -**Good Description Examples:** -- "Pan head phillips screw, #8 size, 3/4 inch long, mild steel, zinc plated" -- "Ceramic capacitor, 0.1 microfarad, 0805 package, 50V rating" -- "M6 hex bolt, 50mm long, zinc plated, metric thread" -- "Arduino Uno R3 development board with ATmega328P" - -**Location Format:** `ModuleName:LevelNumber:RowCol` -- Example: `Zeus:1:A3` means Module "Zeus", Level 1, Location A3 - -### 4. Use the System - -- **Browse:** Navigate Modules → Levels → Locations to see what's where -- **Search:** Find items by keyword in name, description, or tags -- **Edit:** Update quantities, add/remove locations, change descriptions -- **Organize:** Set location types (small_box, liquid_container, etc.) - ---- - -## Sample Data (Optional) - -Want to see the system in action immediately? - -```bash -# Make sure the system is running first -docker-compose ps - -# Run the sample data script -python3 create_sample_data.py -``` - -This creates: -- 3 storage modules (Zeus, Muse, Apollo) -- Multiple levels per module -- 10 sample items (electronics, fasteners, tools, paint) - -You can delete this sample data later or use it as a template. - ---- - -## Accessing from Other Devices - -### On Your Local Network - -1. Find your server's IP: - ```bash - hostname -I - # or - ip addr show - ``` - -2. Access from any device on the network: - ``` - http://192.168.1.100:8080 (use your actual IP) - ``` - -### Over the Internet (VPS only) - -If deployed on a VPS with a public IP: -``` -http://your-public-ip:8080 -``` - -⚠️ **Security Note:** For internet-facing deployments: -- Change default database password -- Set up HTTPS with Let's Encrypt -- Use a reverse proxy (nginx/Caddy) -- Consider authentication (Phase 8) - ---- - -## Container Management - -### View Status -```bash -docker-compose ps -``` - -### View Logs -```bash -# All containers -docker-compose logs -f - -# Specific container -docker-compose logs -f backend -docker-compose logs -f postgres -docker-compose logs -f nginx -``` - -### Restart Containers -```bash -# Restart all -docker-compose restart - -# Restart specific -docker-compose restart backend -``` - -### Stop System (Keep Data) -```bash -docker-compose stop -``` - -### Start System -```bash -docker-compose start -``` - -### Shutdown Completely (Keep Data) -```bash -docker-compose down -``` - -### Nuclear Option (Delete Everything) -```bash -docker-compose down -v -# This deletes the database volume! -``` - ---- - -## Data Backup - -### Backup Database - -```bash -# Backup to SQL file -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup_$(date +%Y%m%d).sql - -# Backup entire data directory -tar -czf backup_$(date +%Y%m%d).tar.gz data/ -``` - -### Restore Database - -```bash -# Restore from SQL file -docker-compose exec -T postgres psql -U inventoryuser inventory < backup_20241026.sql -``` - -### Automated Backups (Cron) - -```bash -# Add to crontab -crontab -e - -# Backup daily at 2 AM -0 2 * * * cd /path/to/inventory-system && docker-compose exec postgres pg_dump -U inventoryuser inventory > /backups/inventory_$(date +\%Y\%m\%d).sql -``` - ---- - -## Troubleshooting - -### Port Already in Use - -Edit `docker-compose.yml`: -```yaml -nginx: - ports: - - "8081:80" # Change 8080 to 8081 -``` - -Then: `docker-compose down && docker-compose up -d` - -### Database Connection Failed - -```bash -# Check if PostgreSQL is running -docker-compose ps - -# Restart PostgreSQL -docker-compose restart postgres - -# View logs -docker-compose logs postgres -``` - -### Web UI Not Loading - -```bash -# Check nginx logs -docker-compose logs nginx - -# Restart nginx -docker-compose restart nginx - -# Rebuild containers -docker-compose up --build -d -``` - -### Backend Errors - -```bash -# View backend logs -docker-compose logs backend - -# Common issue: Database not ready -# Solution: Wait 30 seconds after starting, or restart backend -docker-compose restart backend -``` - -### Reset Everything - -```bash -# Nuclear option - starts fresh -docker-compose down -v -docker-compose up -d -``` - ---- - -## Performance Tuning - -### For Better Performance: - -1. **Increase Docker Memory:** - - Docker Desktop → Settings → Resources → Memory - - Allocate at least 4GB for larger inventories - -2. **Use SSD Storage:** - - Ensure `data/` directory is on an SSD - -3. **PostgreSQL Tuning:** - Edit `docker-compose.yml` and add: - ```yaml - postgres: - command: postgres -c shared_buffers=256MB -c max_connections=100 - ``` - ---- - -## Security Checklist - -For production/internet-facing deployments: - -- [ ] Change default PostgreSQL password in `docker-compose.yml` -- [ ] Set secure `SECRET_KEY` in environment variables -- [ ] Don't expose PostgreSQL port (5432) to the internet -- [ ] Use HTTPS with SSL certificate -- [ ] Set up firewall rules -- [ ] Enable automated backups -- [ ] Update containers regularly: `docker-compose pull && docker-compose up -d` - ---- - -## Next Steps - -### Immediate (Phase 1): -1. ✅ Deploy system and verify it works -2. ✅ Create your first module -3. ✅ Add 10-20 items to test -4. ✅ Experiment with search -5. ✅ Set up daily backups - -### Soon (Phase 2-3): -1. Add smart location suggestions -2. Implement duplicate detection -3. Parse item specifications automatically - -### Future (Phase 4-6): -1. Add AI semantic search -2. Build CLI interface -3. Create voice interface - ---- - -## Getting Help - -### Documentation -- `README.md` - Comprehensive documentation -- `QUICKSTART.md` - 5-minute deployment guide -- `ARCHITECTURE.md` - Technical architecture details -- `PROJECT_SUMMARY.md` - Full project overview - -### Common Questions - -**Q: Can I run this on a Raspberry Pi?** -A: Yes! Use Docker on Raspberry Pi OS. ARM architecture is supported. - -**Q: How many items can it handle?** -A: Tested with 10,000+ items. PostgreSQL can handle millions. - -**Q: Can I import existing inventory from CSV?** -A: Not yet (Phase 5), but you can write a Python script using the API. - -**Q: Does it work offline?** -A: Yes! It's completely self-hosted. No internet required. - -**Q: Can multiple people use it?** -A: Yes, but no user accounts yet (Phase 8). Everyone shares the same view. - ---- - -## Success Criteria - -You'll know Phase 1 is working when you can: - -- ✅ Access the web UI at http://localhost:8080 -- ✅ Create modules, levels, and locations -- ✅ Add items with descriptions -- ✅ Search for items and find them -- ✅ View the location grid -- ✅ See item counts per location -- ✅ Edit and delete items - ---- - -## What's Different from Other Inventory Systems? - -1. **Storage-first design:** Models your actual physical storage -2. **Natural language:** Describe items how you think about them -3. **Flexible locations:** Items can be in multiple places -4. **Future AI integration:** Ready for semantic search (Phase 4) -5. **Voice control ready:** Architecture supports voice UI (Phase 6) -6. **Open source:** Customize it however you want - ---- - -## Files in This Package - -``` -inventory-system/ -├── README.md # Full documentation -├── QUICKSTART.md # 5-minute quick start -├── ARCHITECTURE.md # Technical details -├── PROJECT_SUMMARY.md # Project overview -├── docker-compose.yml # Docker orchestration -├── nginx.conf # Web server config -├── create_sample_data.py # Sample data generator -├── backend/ # Flask application -│ ├── app/ -│ │ ├── models.py # Database models -│ │ ├── routes/ # API endpoints -│ │ └── __init__.py -│ ├── requirements.txt # Python dependencies -│ ├── Dockerfile -│ └── run.py -└── frontend/ # Web UI - ├── templates/ # HTML templates - └── static/ # CSS/JS assets -``` - ---- - -## Version Info - -- **Version:** 1.0.0 -- **Phase:** 1 (Foundation) -- **Date:** October 2024 -- **Status:** ✅ Production Ready (for Phase 1 features) - ---- - -## Quick Command Reference - -```bash -# Deploy -docker-compose up -d - -# Status -docker-compose ps - -# Logs -docker-compose logs -f - -# Restart -docker-compose restart - -# Stop -docker-compose down - -# Backup -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql - -# Load sample data -python3 create_sample_data.py - -# Access -http://localhost:8080 -``` - ---- - -## Ready to Deploy? - -1. Extract the `inventory-system` folder -2. Run `docker-compose up -d` -3. Open `http://localhost:8080` -4. Start organizing! 🎉 - -**Questions?** Check the README.md or open an issue. - -**Happy organizing!** 🏠🔧📦 diff --git a/inventory-system/docs/DEPLOYMENT_SUMMARY.md b/inventory-system/docs/DEPLOYMENT_SUMMARY.md deleted file mode 100644 index 7a63819..0000000 --- a/inventory-system/docs/DEPLOYMENT_SUMMARY.md +++ /dev/null @@ -1,312 +0,0 @@ -# 🎉 Your Homelab Inventory System is Ready! - -## What You Have - -I've created a complete, working **Phase 1** inventory management system with: - -### ✅ Core Features -- **Full storage hierarchy**: Modules → Levels → Locations (with row/column addressing) -- **Complete CRUD operations**: Add, view, edit, delete items, modules, levels -- **Web interface**: Clean, responsive UI that works on desktop and mobile -- **PostgreSQL backend**: Professional database with proper relationships -- **Docker deployment**: One command to start everything -- **RESTful API**: Programmatic access to all data -- **Search functionality**: Find items by name, description, or tags - -### 📁 Project Structure -``` -inventory-system/ -├── README.md # Complete documentation -├── QUICKSTART.md # 5-minute deployment guide -├── docker-compose.yml # Docker orchestration -├── nginx.conf # Reverse proxy config -├── .env.example # Environment template -├── create_sample_data.py # Demo data generator -├── backend/ -│ ├── app/ -│ │ ├── models.py # Database schema -│ │ ├── routes/ # All API endpoints -│ │ └── __init__.py # Flask app -│ ├── requirements.txt -│ ├── Dockerfile -│ └── run.py -└── frontend/ - ├── templates/ # HTML templates - │ ├── base.html - │ ├── index.html - │ ├── modules/ - │ ├── levels/ - │ ├── items/ - │ ├── locations/ - │ └── search/ - └── static/ - ├── css/style.css # Complete styling - └── js/main.js # Client-side logic -``` - -## 🚀 Quick Start (3 Steps) - -### 1. Navigate to the Project -```bash -cd inventory-system -``` - -### 2. Start the System -```bash -docker-compose up -d -``` - -### 3. Open Your Browser -``` -http://localhost:8080 -``` - -That's it! The system is running. - -## 📚 What to Do Next - -### Option A: Try It Empty -1. Open http://localhost:8080 -2. Click "Modules" → "Add Module" -3. Create your first storage module -4. Add levels and start organizing! - -### Option B: Load Sample Data -```bash -# First, install requests if needed -pip install requests - -# Then run the sample data generator -python create_sample_data.py -``` - -This creates: -- 3 storage modules (Zeus, Muse, Apollo) -- Multiple levels with different grid layouts -- 10 sample items across various categories - -## 🎯 Real-World Usage Example - -Let's say you have a cabinet called "Zeus" with 3 drawers: - -1. **Create Module "Zeus"** - - Modules → Add Module - - Name: Zeus - - Description: Main component storage - - Location: North wall - -2. **Add Drawer (Level 1)** - - Click Zeus → Add Level - - Level Number: 1 - - Rows: 4, Columns: 6 - - This creates locations A1-A6, B1-B6, C1-C6, D1-D6 - -3. **Add an Item** - - Items → Add Item - - Name: "M6 Bolts" - - Description: "Hex head bolt, M6 diameter, 50mm long, zinc plated" - - Category: Fasteners - - Quantity: 100, Unit: pieces - - Location: Zeus:1:A3 (Module Zeus, Level 1, Location A3) - -4. **Find It Later** - - Search → "M6" or "bolt" - - Results show the item with location Zeus:1:A3 - - Click to see exact position in the grid - -## 📊 Database Schema - -The system uses a properly normalized PostgreSQL schema: - -``` -Module (e.g., Zeus) - └── Level 1 (4x6 grid) - ├── Location A1 → [Item: M6 Bolts (qty: 100)] - ├── Location A2 → [Empty] - ├── Location A3 → [Item: Resistors (qty: 200)] - └── ... - └── Level 2 (3x4 grid) - └── ... -``` - -## 🔄 Future Phases (Coming Soon) - -**Phase 2: Smart Locations** (Week 3) -- System suggests where to put items -- Location constraints by type/size -- Visual location maps - -**Phase 3: Duplicate Detection** (Week 4) -- Warns about similar items -- Helps avoid redundant storage - -**Phase 4: AI Search** (Week 5-6) -- Natural language queries -- "Find me a long metric bolt around M6" -- Semantic similarity matching - -**Phase 5: CLI** (Week 7) -- Command-line interface -- Batch operations -- Power user features - -**Phase 6: Voice** (Week 8-9) -- "Hey Inventory, where are my M6 bolts?" -- Hands-free workshop operation - -**Phase 7: Advanced AI** (Week 10-11) -- Usage analytics -- Smart reorganization suggestions -- Alternative part recommendations - -**Phase 8: Production** (Week 12+) -- Mobile optimization -- Multi-user support -- QR codes & barcodes - -## 🛠️ Customization - -### Change Ports -Edit `docker-compose.yml`: -```yaml -nginx: - ports: - - "8080:80" # Change 8080 to your preference -``` - -### Change Database Password -Edit `docker-compose.yml`: -```yaml -environment: - POSTGRES_PASSWORD: your-secure-password -``` - -### Add More Location Types -Edit locations in the web UI and choose from: -- general, small_box, medium_bin, large_bin -- liquid_container, smd_container, bulk_storage -- Or add your own custom types! - -## 💾 Backup Your Data - -### Quick Backup -```bash -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql -``` - -### Restore -```bash -docker-compose exec -T postgres psql -U inventoryuser inventory < backup.sql -``` - -## 🐛 Troubleshooting - -**Containers won't start?** -```bash -docker-compose logs backend -docker-compose logs postgres -``` - -**Port already in use?** -Change the port in docker-compose.yml or: -```bash -lsof -i :8080 # Find what's using it -``` - -**Want to start fresh?** -```bash -docker-compose down -v # ⚠️ Deletes all data! -docker-compose up -d -``` - -## 📖 Documentation - -- **README.md**: Complete documentation -- **QUICKSTART.md**: 5-minute setup guide -- **Code comments**: Every file is well-documented -- **API endpoints**: Check README for REST API details - -## 🎓 Learning Path - -1. ✅ **Week 1-2**: Use Phase 1, add 50-100 items -2. 🔄 **Week 3**: Deploy Phase 2 with location suggestions -3. 🔄 **Week 4**: Add duplicate detection -4. 🔄 **Week 5-6**: Enable semantic search -5. 🔄 **Week 7+**: CLI and voice interfaces - -## 🌟 Key Features - -### Storage Hierarchy -- **Modules**: Physical storage units (cabinets, shelves) -- **Levels**: Drawers, compartments within modules -- **Locations**: Individual bins with row/col addressing -- **Items**: Your actual inventory - -### Location Types -Different location types for different needs: -- Small boxes for tiny SMD components -- Medium bins for fasteners -- Large bins for bulk storage -- Liquid containers for paints/chemicals - -### Flexible Search -- Search by item name -- Search by description -- Search by tags -- Filter by category -- Filter by location - -### Visual Grid -- See occupied vs. empty locations -- Click any location to view contents -- Color-coded status indicators - -## 🔐 Security Notes - -For production: -1. Change PostgreSQL password -2. Set secure SECRET_KEY -3. Enable HTTPS -4. Set up firewall rules -5. Configure backups - -## 🤝 Support - -Read the documentation: -- README.md for detailed info -- QUICKSTART.md for quick help - -Check the logs: -```bash -docker-compose logs -``` - -## 📈 Performance - -Current Phase 1 handles: -- ✅ Thousands of items -- ✅ Hundreds of locations -- ✅ Multiple concurrent users -- ✅ Fast keyword search - -Future phases will add: -- AI-powered semantic search -- Voice recognition -- More advanced features - -## 🎉 You're All Set! - -Your inventory system is ready to use. Start by: - -1. Opening http://localhost:8080 -2. Creating your first module -3. Adding some items -4. Testing the search - -**Enjoy organizing your workshop!** 🛠️ - ---- - -**Need Help?** Check README.md and QUICKSTART.md for detailed guides. - -**Ready for More?** Once comfortable with Phase 1, we'll add smart location suggestions, duplicate detection, and AI search! diff --git a/inventory-system/docs/FILE_INDEX.md b/inventory-system/docs/FILE_INDEX.md deleted file mode 100644 index 18beab1..0000000 --- a/inventory-system/docs/FILE_INDEX.md +++ /dev/null @@ -1,494 +0,0 @@ -# 📁 Complete File Index - -## Project Statistics - -- **Total Files**: 36 source files -- **Lines of Code**: 3,158 (Python, HTML, CSS, JavaScript) -- **Documentation**: 7 comprehensive guides -- **Total Size**: ~147KB (excluding Docker volumes) - -## 📚 Documentation Files (7 files) - -### Primary Guides -1. **README.md** (400+ lines) - - Complete user and developer guide - - Prerequisites and installation - - Usage examples - - API documentation - - Troubleshooting - - Backup procedures - -2. **QUICKSTART.md** (300+ lines) - - 5-minute deployment guide - - First-time setup walkthrough - - Common tasks - - Quick troubleshooting - -3. **DEPLOYMENT_SUMMARY.md** (250+ lines) - - What's included overview - - Quick start steps - - Real-world usage examples - - Future phases roadmap - - Customization guide - -4. **ARCHITECTURE.md** (600+ lines) - - Technical architecture - - System diagrams - - Database schema with SQL - - API endpoint documentation - - Security considerations - - Performance characteristics - -5. **PROJECT_SUMMARY.md** (500+ lines) - - Complete project overview - - Deliverables summary - - Technical achievements - - Future roadmap - - Success metrics - -### Additional Guides -6. **GETTING_STARTED_CHECKLIST.md** (200+ lines) - - Step-by-step checklist - - Testing procedures - - Daily usage guide - - Maintenance tasks - -7. **VERSION.md** (200+ lines) - - Version information - - Feature changelog - - Phase roadmap - - Compatibility matrix - - Dependencies - -## 🐍 Python Backend Files (8 files) - -### Application Core -1. **backend/app/__init__.py** (40 lines) - - Flask application factory - - Extension initialization - - Blueprint registration - - Database creation - -2. **backend/app/models.py** (350 lines) - - Module model - - Level model - - Location model - - Item model - - ItemLocation junction model - - Relationships and constraints - - Helper methods - -### Route Handlers -3. **backend/app/routes/main.py** (30 lines) - - Dashboard route - - Statistics aggregation - - About page - -4. **backend/app/routes/modules.py** (280 lines) - - Module CRUD operations - - Level CRUD operations - - Location grid generation - - API endpoints for modules - -5. **backend/app/routes/items.py** (200 lines) - - Item CRUD operations - - Location assignment - - Multi-location support - - API endpoints for items - -6. **backend/app/routes/locations.py** (100 lines) - - Location listing with filters - - Location details - - Location editing - - API endpoints for locations - -7. **backend/app/routes/search.py** (50 lines) - - Keyword search - - Multi-field search (name, description, tags) - - API search endpoint - -### Application Runner -8. **backend/run.py** (30 lines) - - Development server starter - - Database initialization - - Configuration loading - -## 🎨 Frontend Template Files (15 files) - -### Base Templates -1. **frontend/templates/base.html** (60 lines) - - Page structure - - Navigation menu - - Flash message display - - Footer - -2. **frontend/templates/index.html** (80 lines) - - Dashboard layout - - Statistics cards - - Quick actions - - Recent items table - -### Module Templates -3. **frontend/templates/modules/list.html** (50 lines) - - Module listing - - Module cards - - Empty state - -4. **frontend/templates/modules/view.html** (60 lines) - - Module details - - Level listing - - Statistics - -5. **frontend/templates/modules/form.html** (50 lines) - - Create/edit module form - - Validation - - Delete option - -### Level Templates -6. **frontend/templates/levels/form.html** (60 lines) - - Create/edit level form - - Grid configuration - - Warning messages - -7. **frontend/templates/levels/view.html** (80 lines) - - Location grid display - - Interactive cells - - Legend - -### Item Templates -8. **frontend/templates/items/list.html** (70 lines) - - Item listing table - - Filter form - - Search integration - -9. **frontend/templates/items/view.html** (90 lines) - - Item details - - Location display - - Add/remove locations - -10. **frontend/templates/items/form.html** (120 lines) - - Create/edit item form - - Category selection - - Location picker - - Tag input - -### Location Templates -11. **frontend/templates/locations/list.html** (70 lines) - - Location listing - - Filter options - - Status indicators - -12. **frontend/templates/locations/view.html** (70 lines) - - Location details - - Items stored - - Breadcrumb navigation - -13. **frontend/templates/locations/form.html** (60 lines) - - Edit location properties - - Type selection - - Dimension inputs - -### Search Templates -14. **frontend/templates/search/results.html** (60 lines) - - Search form - - Results table - - Empty state - -## 🎨 Styling & Scripts (2 files) - -1. **frontend/static/css/style.css** (1,100 lines) - - Complete design system - - CSS custom properties - - Responsive layouts - - Component styles - - Grid system - - Forms - - Tables - - Cards - - Navigation - - Utilities - -2. **frontend/static/js/main.js** (100 lines) - - Flash message auto-hide - - Confirm dialogs - - Dynamic location selector - - Form enhancements - -## 🐋 Infrastructure Files (4 files) - -1. **docker-compose.yml** (50 lines) - - Multi-container orchestration - - PostgreSQL service - - Flask backend service - - nginx proxy service - - Volume definitions - - Health checks - -2. **backend/Dockerfile** (20 lines) - - Python base image - - System dependencies - - Package installation - - Application setup - -3. **nginx.conf** (25 lines) - - Reverse proxy configuration - - Upstream backend - - Static file serving - - Headers - -4. **backend/requirements.txt** (6 lines) - - Flask 3.0 - - SQLAlchemy 2.0 - - psycopg2 - - Flask-Migrate - - python-dotenv - -## 🔧 Configuration Files (2 files) - -1. **.env.example** (15 lines) - - Database configuration - - Flask settings - - Secret keys - - Port configuration - -2. **.gitignore** (50 lines) - - Python artifacts - - Virtual environments - - Database files - - IDE files - - Environment files - - Logs - -## 🧪 Utility Scripts (1 file) - -1. **create_sample_data.py** (200 lines) - - Sample data generator - - Creates 3 modules - - Creates multiple levels - - Creates 10 sample items - - Server health check - - Progress reporting - -## 📊 File Breakdown by Type - -### By Language -``` -Python: ~1,200 lines (8 files) -HTML: ~1,100 lines (15 files) -CSS: ~1,100 lines (1 file) -JavaScript: ~100 lines (1 file) -Configuration: ~100 lines (4 files) -Documentation: ~2,500 lines (7 files) -``` - -### By Purpose -``` -Core Application: ~1,500 lines (Python backend + models) -User Interface: ~2,200 lines (HTML templates + CSS + JS) -Infrastructure: ~100 lines (Docker, nginx) -Documentation: ~2,500 lines (7 guides) -Configuration/Utils: ~300 lines (config, scripts) -``` - -## 🗂️ Directory Structure - -``` -inventory-system/ -│ -├── Documentation (7 files) -│ ├── README.md -│ ├── QUICKSTART.md -│ ├── DEPLOYMENT_SUMMARY.md -│ ├── ARCHITECTURE.md -│ ├── PROJECT_SUMMARY.md -│ ├── GETTING_STARTED_CHECKLIST.md -│ └── VERSION.md -│ -├── Configuration (4 files) -│ ├── docker-compose.yml -│ ├── nginx.conf -│ ├── .env.example -│ └── .gitignore -│ -├── Utilities (1 file) -│ └── create_sample_data.py -│ -├── backend/ -│ ├── App Core (2 files) -│ │ ├── app/__init__.py -│ │ └── app/models.py -│ │ -│ ├── Routes (5 files) -│ │ ├── app/routes/main.py -│ │ ├── app/routes/modules.py -│ │ ├── app/routes/items.py -│ │ ├── app/routes/locations.py -│ │ └── app/routes/search.py -│ │ -│ ├── Configuration (2 files) -│ │ ├── requirements.txt -│ │ └── Dockerfile -│ │ -│ └── Runner (1 file) -│ └── run.py -│ -└── frontend/ - ├── static/ - │ ├── css/ - │ │ └── style.css (1 file) - │ └── js/ - │ └── main.js (1 file) - │ - └── templates/ - ├── Base (2 files) - │ ├── base.html - │ └── index.html - │ - ├── modules/ (3 files) - │ ├── list.html - │ ├── view.html - │ └── form.html - │ - ├── levels/ (2 files) - │ ├── view.html - │ └── form.html - │ - ├── items/ (3 files) - │ ├── list.html - │ ├── view.html - │ └── form.html - │ - ├── locations/ (3 files) - │ ├── list.html - │ ├── view.html - │ └── form.html - │ - └── search/ (1 file) - └── results.html -``` - -## 📈 Code Quality Metrics - -### Python Code -- **Modularity**: Excellent (blueprints, models separated) -- **Documentation**: Comprehensive (docstrings, comments) -- **Error Handling**: Good (try-except, validation) -- **Code Style**: PEP 8 compliant -- **Complexity**: Low to medium - -### HTML/CSS -- **Semantic HTML**: Yes (HTML5 elements) -- **Accessibility**: Good (labels, alt text) -- **Responsive**: Yes (mobile-friendly) -- **Maintainability**: Excellent (CSS variables, consistent naming) -- **Browser Support**: Modern browsers - -### JavaScript -- **Modern Syntax**: ES6+ -- **Vanilla JS**: No framework dependencies -- **Progressive Enhancement**: Yes -- **Error Handling**: Basic - -## 🔍 Key Features by File - -### Database Models (models.py) -- 5 models with relationships -- Cascade deletes -- Unique constraints -- JSON metadata support -- Helper methods (to_dict, full_address) - -### Route Handlers -- **main.py**: Dashboard with statistics -- **modules.py**: Full module/level CRUD + API -- **items.py**: Item management with multi-location -- **locations.py**: Location filtering and viewing -- **search.py**: Keyword search with wildcards - -### Templates -- **base.html**: Navigation, flash messages, structure -- **index.html**: Dashboard with quick actions -- **Module views**: List, detail, form patterns -- **Item views**: Complex forms with location picker -- **Location views**: Grid visualization -- **Search**: Results with filtering - -### Styling (style.css) -- CSS custom properties for theming -- Responsive grid system -- Component library (cards, tables, forms) -- Interactive grid cells -- Alert system -- Mobile-friendly - -## 🎯 Most Important Files - -### For Users -1. **QUICKSTART.md** - Start here -2. **README.md** - Complete guide -3. **docker-compose.yml** - One-command deploy - -### For Developers -1. **models.py** - Database schema -2. **ARCHITECTURE.md** - System design -3. **modules.py** - Example route patterns - -### For Operators -1. **docker-compose.yml** - Deployment config -2. **.env.example** - Configuration template -3. **QUICKSTART.md** - Troubleshooting - -## 📝 Lines of Code by Component - -``` -Database Models: ~350 lines -Route Handlers: ~660 lines -Templates: ~1,100 lines -Styling (CSS): ~1,100 lines -JavaScript: ~100 lines -Configuration: ~100 lines -Documentation: ~2,500 lines -Utilities: ~200 lines -─────────────────────────────── -Total: ~6,110 lines -``` - -## 🎉 Completeness Check - -### Documentation ✅ -- [x] User guide (README) -- [x] Quick start -- [x] Architecture docs -- [x] Deployment guide -- [x] Version info -- [x] Checklist -- [x] Project summary - -### Code ✅ -- [x] Database models -- [x] All routes -- [x] All templates -- [x] Complete styling -- [x] JavaScript utilities -- [x] Sample data script - -### Infrastructure ✅ -- [x] Docker Compose -- [x] Dockerfiles -- [x] nginx config -- [x] Environment template - -### Quality ✅ -- [x] Code comments -- [x] Docstrings -- [x] Error handling -- [x] Input validation -- [x] Responsive design - ---- - -**Total Project Scope**: 36 files, 6,100+ lines, 7 comprehensive guides - -**Status**: Phase 1 Complete ✅ - -**Next**: Deploy and use, then proceed to Phase 2! diff --git a/inventory-system/docs/GETTING_STARTED_CHECKLIST.md b/inventory-system/docs/GETTING_STARTED_CHECKLIST.md deleted file mode 100644 index 80dfedc..0000000 --- a/inventory-system/docs/GETTING_STARTED_CHECKLIST.md +++ /dev/null @@ -1,237 +0,0 @@ -# ✅ Getting Started Checklist - -Use this checklist to deploy and start using your inventory system. - -## Pre-Deployment Checks - -- [ ] Docker installed (`docker --version`) -- [ ] Docker Compose installed (`docker-compose --version`) -- [ ] At least 2GB RAM available -- [ ] At least 10GB disk space available -- [ ] Ports 8080, 5432, 5000 are not in use - -## Deployment Steps - -- [ ] Navigate to project directory: `cd inventory-system` -- [ ] Review QUICKSTART.md -- [ ] Start the system: `docker-compose up -d` -- [ ] Wait 30-60 seconds for containers to start -- [ ] Verify all containers running: `docker-compose ps` -- [ ] Access web interface: http://localhost:8080 - -## First-Time Setup - -- [ ] Dashboard loads successfully -- [ ] Create first module (e.g., "Zeus") -- [ ] Add first level to module (e.g., 4x6 grid) -- [ ] View level to see location grid -- [ ] Create first item -- [ ] Assign item to a location -- [ ] Test search functionality - -## Optional: Load Sample Data - -- [ ] Install Python requests: `pip install requests` -- [ ] Run sample data script: `python create_sample_data.py` -- [ ] Verify data loaded in web interface -- [ ] Explore sample modules (Zeus, Muse, Apollo) -- [ ] View sample items and locations - -## Testing Checklist - -### Module Management -- [ ] Create a module -- [ ] Edit module details -- [ ] View module with levels -- [ ] Delete a test module - -### Level Management -- [ ] Add level to module -- [ ] Configure grid size (rows x columns) -- [ ] View location grid -- [ ] Edit level configuration -- [ ] Delete a test level - -### Item Management -- [ ] Create an item -- [ ] Add description and tags -- [ ] Assign to location -- [ ] View item details -- [ ] Edit item information -- [ ] Add second location to item -- [ ] Remove location from item -- [ ] Delete a test item - -### Location Management -- [ ] View locations list -- [ ] Filter locations (occupied/empty) -- [ ] View location details -- [ ] Edit location properties -- [ ] Set location type -- [ ] Add dimensions to location - -### Search & Discovery -- [ ] Search by item name -- [ ] Search by description keyword -- [ ] Search by tag -- [ ] View search results -- [ ] Click through to item details -- [ ] Navigate to item location - -## Real Data Migration - -- [ ] Plan storage module structure -- [ ] Create all physical modules -- [ ] Add levels with accurate grids -- [ ] Start with high-value items -- [ ] Add 10 items -- [ ] Add 50 items -- [ ] Add 100 items -- [ ] Label physical locations (optional) -- [ ] Test workflow in real usage - -## Daily Usage - -- [ ] Add new items as acquired -- [ ] Update quantities when used -- [ ] Search when looking for items -- [ ] Keep descriptions accurate -- [ ] Use tags consistently -- [ ] Note location changes - -## Maintenance - -- [ ] Review dashboard statistics weekly -- [ ] Backup database monthly: `docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql` -- [ ] Check disk space -- [ ] Review logs if issues: `docker-compose logs` -- [ ] Update items that moved -- [ ] Archive or delete obsolete items - -## Troubleshooting - -If something doesn't work: - -- [ ] Check all containers running: `docker-compose ps` -- [ ] View backend logs: `docker-compose logs backend` -- [ ] View database logs: `docker-compose logs postgres` -- [ ] View nginx logs: `docker-compose logs nginx` -- [ ] Restart services: `docker-compose restart` -- [ ] Check QUICKSTART.md troubleshooting section -- [ ] Check README.md for detailed help - -## Performance Checks - -After adding significant data: - -- [ ] Search responds in < 1 second -- [ ] Pages load quickly -- [ ] No database errors in logs -- [ ] Disk space sufficient -- [ ] Container memory usage acceptable - -## Security Hardening (Production) - -If exposing to network: - -- [ ] Change PostgreSQL password in docker-compose.yml -- [ ] Set secure SECRET_KEY in .env -- [ ] Configure firewall rules -- [ ] Set up HTTPS with nginx + Let's Encrypt -- [ ] Restrict database port (5432) access -- [ ] Set up automated backups -- [ ] Enable audit logging -- [ ] Review nginx access logs - -## Ready for Phase 2? - -Before adding Phase 2 features: - -- [ ] 50+ items in system -- [ ] Comfortable with web interface -- [ ] Storage hierarchy makes sense -- [ ] Search works well -- [ ] Ready for smart location suggestions -- [ ] Ready for duplicate detection -- [ ] Identified pain points to improve - -## Phase 2 Preparation - -- [ ] Document desired location suggestion behavior -- [ ] Note which items are hard to place -- [ ] Identify duplicate items manually -- [ ] List location types needed -- [ ] Consider physical labeling strategy - -## Success Indicators - -You're successfully using the system when: - -- [ ] You find items without looking physically -- [ ] You know where everything is -- [ ] You avoid buying duplicates -- [ ] Adding items is quick and easy -- [ ] Search saves you time -- [ ] Workshop feels more organized -- [ ] You trust the system data - -## Next Steps - -- [ ] Use system for 1 week -- [ ] Add 100+ items -- [ ] Identify features you need most -- [ ] Decide which phase to deploy next -- [ ] Consider CLI for power users -- [ ] Plan for voice interface -- [ ] Think about semantic search use cases - ---- - -## Quick Commands Reference - -```bash -# Start system -docker-compose up -d - -# Stop system -docker-compose stop - -# Restart system -docker-compose restart - -# View logs -docker-compose logs -f - -# Stop and remove (keeps data) -docker-compose down - -# Nuclear option (deletes ALL data) -docker-compose down -v - -# Backup database -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql - -# Restore database -docker-compose exec -T postgres psql -U inventoryuser inventory < backup.sql - -# Check status -docker-compose ps - -# Load sample data -python create_sample_data.py -``` - ---- - -**Date Started**: _________________ - -**Date Completed Setup**: _________________ - -**First Real Item Added**: _________________ - -**Items in System**: _______ (update weekly) - -**Notes**: -_________________________________________ -_________________________________________ -_________________________________________ diff --git a/inventory-system/docs/INDEX.md b/inventory-system/docs/INDEX.md deleted file mode 100644 index 48f0981..0000000 --- a/inventory-system/docs/INDEX.md +++ /dev/null @@ -1,457 +0,0 @@ -# 📚 Documentation Index - -Welcome! This guide helps you navigate all the documentation for the Homelab Inventory System. - ---- - -## 🚀 Start Here - -### New User? Read These First: - -1. **[PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md)** ⭐ START HERE - - What is this system? - - What can it do? - - Quick start guide - - **Time: 10 minutes** - -2. **[QUICKSTART.md](QUICKSTART.md)** - - Deploy in 5 minutes - - First-time setup - - Load sample data - - **Time: 5 minutes** - -3. **[QUICK_REFERENCE.md](QUICK_REFERENCE.md)** - - Essential commands - - Common workflows - - Troubleshooting tips - - **Time: 5 minutes to read, keep for reference** - ---- - -## 📖 Complete Documentation - -### Full Guides - -**[README.md](inventory-system/README.md)** - Complete Documentation -- Full feature list -- Detailed usage guide -- API documentation -- Development info -- **Time: 30 minutes, reference document** - -**[DEPLOY.md](DEPLOY.md)** - Deployment Guide -- All deployment options -- VPS, Proxmox, Jetson setup -- Production hardening -- Security checklist -- **Time: 20 minutes** - -**[ROADMAP.md](ROADMAP.md)** - Development Roadmap -- Complete 8-phase plan -- Feature timeline -- Technical details -- Phase dependencies -- **Time: 30 minutes** - -**[TESTING_CHECKLIST.md](TESTING_CHECKLIST.md)** - Testing Guide -- Verification procedures -- Test all features -- Troubleshooting tests -- Success criteria -- **Time: 1 hour to complete all tests** - ---- - -## 🗺️ Navigation by Task - -### I Want to... - -#### Get Started -→ Read: **PROJECT_OVERVIEW.md** -→ Then: **QUICKSTART.md** -→ Deploy and test! - -#### Deploy the System -→ Read: **QUICKSTART.md** (simple) or **DEPLOY.md** (comprehensive) -→ Run: `docker-compose up -d` -→ Verify: **TESTING_CHECKLIST.md** - -#### Learn Daily Operations -→ Read: **QUICK_REFERENCE.md** -→ Bookmark for daily use -→ Print and keep near workstation - -#### Understand Features -→ Read: **README.md** (complete docs) -→ Check: **PROJECT_OVERVIEW.md** (summary) -→ Try: Sample data - -#### Plan Future Phases -→ Read: **ROADMAP.md** -→ Understand phase dependencies -→ Choose next features - -#### Troubleshoot Issues -→ Check: **QUICK_REFERENCE.md** (common issues) -→ Try: **TESTING_CHECKLIST.md** (verify setup) -→ Review: Logs with `docker-compose logs` - -#### Deploy to Production -→ Read: **DEPLOY.md** (full guide) -→ Follow: Security checklist -→ Set up: Automated backups - -#### Test Everything -→ Use: **TESTING_CHECKLIST.md** -→ Verify: All features work -→ Document: Any issues - ---- - -## 📋 Document Purpose Guide - -| Document | Purpose | When to Use | Time | -|----------|---------|-------------|------| -| PROJECT_OVERVIEW.md | Big picture overview | Starting out | 10 min | -| QUICKSTART.md | Fast deployment | Want it running now | 5 min | -| README.md | Complete reference | Need detailed info | 30 min | -| DEPLOY.md | Production deployment | Serious deployment | 20 min | -| ROADMAP.md | Future planning | Curious about phases | 30 min | -| QUICK_REFERENCE.md | Daily cheat sheet | Using the system | 5 min | -| TESTING_CHECKLIST.md | Verification | After deployment | 60 min | - ---- - -## 🎯 Reading Paths - -### Path 1: Quick Start (15 minutes) -1. PROJECT_OVERVIEW.md (10 min) -2. QUICKSTART.md (5 min) -3. Deploy and test - -**Best for:** Getting started fast - -### Path 2: Comprehensive (90 minutes) -1. PROJECT_OVERVIEW.md (10 min) -2. README.md (30 min) -3. DEPLOY.md (20 min) -4. QUICKSTART.md (5 min) -5. Deploy -6. TESTING_CHECKLIST.md (60 min) -7. QUICK_REFERENCE.md (5 min) - -**Best for:** Thorough understanding - -### Path 3: Production Deploy (60 minutes) -1. PROJECT_OVERVIEW.md (10 min) -2. DEPLOY.md (20 min) -3. Deploy with production settings -4. TESTING_CHECKLIST.md (60 min) -5. Set up backups -6. QUICK_REFERENCE.md (5 min) - -**Best for:** Production deployment - -### Path 4: Developer (120 minutes) -1. README.md (30 min) -2. Review code in inventory-system/ -3. ROADMAP.md (30 min) -4. Deploy and test -5. TESTING_CHECKLIST.md (60 min) -6. Plan customizations - -**Best for:** Extending the system - ---- - -## 📂 File Structure - -``` -/ -├── PROJECT_OVERVIEW.md ⭐ Start here! -├── QUICKSTART.md Fast deployment -├── README.md In inventory-system/ -├── DEPLOY.md Deployment guide -├── ROADMAP.md Future plans -├── QUICK_REFERENCE.md Daily cheat sheet -├── TESTING_CHECKLIST.md Verification -└── inventory-system/ The actual system - ├── docker-compose.yml - ├── backend/ - ├── frontend/ - └── create_sample_data.py -``` - ---- - -## 🆘 Help! I Need... - -### To deploy quickly -→ **QUICKSTART.md** - -### To understand what this is -→ **PROJECT_OVERVIEW.md** - -### Detailed information -→ **README.md** in inventory-system/ - -### Production deployment -→ **DEPLOY.md** - -### Daily commands -→ **QUICK_REFERENCE.md** - -### Future features -→ **ROADMAP.md** - -### To verify it works -→ **TESTING_CHECKLIST.md** - -### Troubleshooting -→ **QUICK_REFERENCE.md** then **README.md** - ---- - -## 💡 Tips for Reading - -### For Beginners -- Start with PROJECT_OVERVIEW.md -- Don't try to read everything at once -- Deploy using QUICKSTART.md -- Keep QUICK_REFERENCE.md handy -- Come back to other docs as needed - -### For Advanced Users -- Skim PROJECT_OVERVIEW.md -- Jump straight to deployment -- Reference README.md for details -- Check ROADMAP.md for future features -- Use TESTING_CHECKLIST.md thoroughly - -### For Production -- Read DEPLOY.md carefully -- Follow security checklist -- Complete TESTING_CHECKLIST.md -- Set up automated backups -- Keep QUICK_REFERENCE.md accessible - ---- - -## 🔖 Bookmarks - -Print or bookmark these for quick access: - -### Daily Use -- QUICK_REFERENCE.md (commands) -- TESTING_CHECKLIST.md (troubleshooting section) - -### Occasional Reference -- README.md (complete docs) -- DEPLOY.md (production tips) - -### Planning -- ROADMAP.md (feature planning) -- PROJECT_OVERVIEW.md (big picture) - ---- - -## 📝 Documentation Map - -``` - PROJECT_OVERVIEW.md - | - [Quick Summary] - | - +---------+----------+ - | | - QUICKSTART.md README.md - [Fast Deploy] [Complete Docs] - | | - +-------- + ---------+ - | - DEPLOY.md - [Production Guide] - | - | - TESTING_CHECKLIST.md - [Verify] - | - | - QUICK_REFERENCE.md - [Daily Use] - | - | - ROADMAP.md - [Future Plans] -``` - ---- - -## ✅ Checklist: Have You Read? - -Before deploying: -- [ ] PROJECT_OVERVIEW.md -- [ ] QUICKSTART.md or DEPLOY.md - -After deploying: -- [ ] TESTING_CHECKLIST.md (at least critical tests) -- [ ] QUICK_REFERENCE.md (for daily operations) - -For production: -- [ ] DEPLOY.md (security section) -- [ ] TESTING_CHECKLIST.md (complete) - -Optional but recommended: -- [ ] README.md (comprehensive reference) -- [ ] ROADMAP.md (understand future) - ---- - -## 🎓 Learning Progression - -### Week 1: Getting Started -- Read: PROJECT_OVERVIEW.md -- Deploy: Using QUICKSTART.md -- Test: Basic tests from TESTING_CHECKLIST.md -- Use: Add first 20 items - -### Week 2: Daily Operations -- Master: QUICK_REFERENCE.md -- Complete: TESTING_CHECKLIST.md -- Organize: Add more items -- Refine: Storage organization - -### Week 3: Advanced -- Read: Complete README.md -- Explore: API endpoints -- Review: ROADMAP.md -- Plan: Next phase needs - -### Week 4+: Mastery -- Optimize: Storage layout -- Automate: Backup scripts -- Customize: Add features -- Prepare: For Phase 2 - ---- - -## 📞 Still Lost? - -### Read This Order: -1. PROJECT_OVERVIEW.md (the big picture) -2. QUICKSTART.md (get it running) -3. Use the system for a day -4. QUICK_REFERENCE.md (daily operations) -5. Come back to other docs as needed - -### Common Mistakes: -- ❌ Trying to read everything first -- ❌ Skipping PROJECT_OVERVIEW.md -- ❌ Not testing after deployment -- ❌ Forgetting QUICK_REFERENCE.md - -### Best Approach: -- ✅ Read PROJECT_OVERVIEW.md -- ✅ Deploy with QUICKSTART.md -- ✅ Load sample data -- ✅ Use the system -- ✅ Reference docs as needed - ---- - -## 🗂️ Documentation Stats - -| Document | Pages | Read Time | Update Frequency | -|----------|-------|-----------|------------------| -| PROJECT_OVERVIEW.md | ~8 | 10 min | Each phase | -| QUICKSTART.md | ~4 | 5 min | Rarely | -| README.md | ~15 | 30 min | Each phase | -| DEPLOY.md | ~12 | 20 min | Each phase | -| ROADMAP.md | ~20 | 30 min | Each phase | -| QUICK_REFERENCE.md | ~8 | 5 min | As needed | -| TESTING_CHECKLIST.md | ~12 | 60 min | Each phase | - ---- - -## 🎯 Quick Decision Tree - -**Just want to try it?** -→ PROJECT_OVERVIEW.md + QUICKSTART.md - -**Need to deploy for real?** -→ DEPLOY.md + TESTING_CHECKLIST.md - -**Want all the details?** -→ README.md - -**Daily operations?** -→ QUICK_REFERENCE.md - -**Planning future?** -→ ROADMAP.md - -**Something broken?** -→ QUICK_REFERENCE.md troubleshooting section - ---- - -## 🏁 Final Recommendations - -### Absolute Minimum -Must read: -1. PROJECT_OVERVIEW.md -2. QUICKSTART.md - -### Recommended -Also read: -3. QUICK_REFERENCE.md -4. README.md (skim) - -### Complete -Read all documents in this order: -1. PROJECT_OVERVIEW.md -2. QUICKSTART.md -3. Deploy system -4. TESTING_CHECKLIST.md -5. QUICK_REFERENCE.md -6. README.md -7. DEPLOY.md (if production) -8. ROADMAP.md (for planning) - ---- - -## 📚 Additional Resources - -### In the Code -- `inventory-system/backend/app/models.py` - Database schema -- `inventory-system/backend/app/routes/` - API endpoints -- `inventory-system/docker-compose.yml` - Container config - -### Generated by System -- Docker logs: `docker-compose logs` -- Database: Connect with psql -- Backups: Created in `data/` directory - ---- - -## 🎊 Ready to Start? - -**Recommended first steps:** - -1. Read PROJECT_OVERVIEW.md (10 minutes) -2. Read QUICKSTART.md (5 minutes) -3. Deploy: `docker-compose up -d` (1 minute) -4. Load sample data: `python3 create_sample_data.py` (1 minute) -5. Explore at http://localhost:8080 (as long as you want!) - -**Total time to working system: ~20 minutes** - ---- - -**Questions?** Check the relevant document above or the troubleshooting sections. - -**Ready?** Start with [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md)! - ---- - -*Documentation Index - Version 1.0.0 - October 2024* diff --git a/inventory-system/docs/PROJECT_OVERVIEW.md b/inventory-system/docs/PROJECT_OVERVIEW.md deleted file mode 100644 index 765068b..0000000 --- a/inventory-system/docs/PROJECT_OVERVIEW.md +++ /dev/null @@ -1,665 +0,0 @@ -# 🏠 Homelab Inventory System - Complete Package - -## What Is This? - -A comprehensive, AI-ready inventory management system designed specifically for homelabs, makerspaces, and workshops. Track thousands of items (electronics, fasteners, tools, paints, etc.) across organized storage with natural language descriptions and future AI capabilities. - -**Current Status: Phase 1 Complete ✅** - -This is a **fully functional, production-ready system** you can deploy and start using immediately. - ---- - -## 📦 Package Contents - -This package includes everything needed to run your inventory system: - -### Core System Files -``` -inventory-system/ -├── docker-compose.yml # Orchestration config -├── nginx.conf # Web server config -├── backend/ # Flask application -│ ├── app/ -│ │ ├── models.py # Database models -│ │ ├── routes/ # API endpoints -│ │ └── __init__.py # App initialization -│ ├── requirements.txt # Python dependencies -│ ├── Dockerfile # Container definition -│ └── run.py # Application entry point -└── frontend/ # Web UI - ├── templates/ # HTML templates - └── static/ # CSS/JS assets -``` - -### Documentation Files -- `README.md` - Complete user and technical documentation -- `QUICKSTART.md` - 5-minute deployment guide -- `DEPLOY.md` - Comprehensive deployment guide -- `ROADMAP.md` - 8-phase development plan -- `QUICK_REFERENCE.md` - Daily operations cheat sheet -- `TESTING_CHECKLIST.md` - Verification procedures -- `create_sample_data.py` - Sample data generator - ---- - -## 🚀 Quick Start (3 Steps) - -### 1. Prerequisites -- Docker & Docker Compose -- 2GB RAM, 10GB disk -- Ports 5000, 5432, 8080 available - -### 2. Deploy -```bash -cd inventory-system -docker-compose up -d -``` - -### 3. Access -Open browser: `http://localhost:8080` - -**Done!** You now have a working inventory system. - ---- - -## ✨ What You Get (Phase 1) - -### Core Features -- ✅ **Storage Hierarchy**: Modules → Levels → Locations -- ✅ **Full CRUD**: Create, read, update, delete everything -- ✅ **Web UI**: Clean, responsive interface -- ✅ **Search**: Find items by keyword -- ✅ **Grid Visualization**: See your storage layout -- ✅ **Flexible Locations**: Items can be in multiple places -- ✅ **Quantity Tracking**: Know what you have -- ✅ **Categorization**: Organize by type -- ✅ **Tagging**: Cross-reference items -- ✅ **REST API**: Programmatic access -- ✅ **Docker Deployment**: Runs anywhere - -### Technology Stack -- **Backend**: Python 3.11+ with Flask -- **Database**: PostgreSQL 15 -- **ORM**: SQLAlchemy -- **Frontend**: Jinja2 templates -- **Deployment**: Docker Compose -- **Web Server**: nginx - -### Data Model -``` -Module (Storage Unit) - └── Level (Drawer/Shelf) - └── Location (Bin/Position) - └── Items (Inventory) - -Example: -Zeus (cabinet) - └── Level 1 (top drawer) - └── Location A3 - └── M6 Bolts (100 pcs) -``` - ---- - -## 🔮 What's Coming (Future Phases) - -This is just the beginning! Here's what's planned: - -### Phase 2: Smart Location Management (Week 3) -- System suggests where to store items -- Location compatibility checking -- Space utilization tracking -- Auto-organization hints - -### Phase 3: Duplicate Detection (Week 4) -- Automatic duplicate detection -- Parse item specifications -- Warn before creating duplicates -- Suggest consolidation - -### Phase 4: Semantic Search (Weeks 5-6) -- AI-powered natural language search -- "Find long metric bolts" actually works -- Similarity-based results -- BERT/Transformer models - -### Phase 5: CLI Interface (Week 7) -- Command-line tool (`invctl`) -- Scriptable operations -- Batch import/export -- Interactive shell - -### Phase 6: Voice Interface (Weeks 8-9) -- Wake word activation -- Hands-free operation -- Voice commands -- Workshop-ready - -### Phase 7: Advanced AI (Weeks 10-11) -- Usage analytics -- Smart recommendations -- Auto-categorization -- Predictive restocking - -### Phase 8: Production Polish (Week 12+) -- Multi-user support -- Mobile optimization -- QR code/barcode scanning -- Professional features - -**Total Development Time: ~3 months for complete system** - ---- - -## 📚 Documentation Guide - -### Getting Started -1. **Start here**: `QUICKSTART.md` - Get running in 5 minutes -2. **Then read**: `README.md` - Full documentation -3. **For deployment**: `DEPLOY.md` - Comprehensive guide - -### Daily Use -- **Quick Reference**: `QUICK_REFERENCE.md` - Commands and tips -- **Troubleshooting**: `README.md` - Common issues section - -### Planning -- **Roadmap**: `ROADMAP.md` - Future features and timeline -- **Testing**: `TESTING_CHECKLIST.md` - Verify everything works - -### Development -- **Architecture**: Models and relationships in code -- **API Docs**: README.md → API Endpoints section - ---- - -## 🎯 Use Cases - -Perfect for: -- **Homelabs**: Track server parts, cables, tools -- **Makerspaces**: Manage shared inventory -- **Workshops**: Organize fasteners, materials, tools -- **Electronics**: Store components, modules, equipment -- **General**: Any organized storage needs - -### Example Inventories - -**Electronics Lab:** -``` -Zeus Module (Component Cabinet) -├── Level 1: Resistors (5×8 grid) -├── Level 2: Capacitors (4×6 grid) -├── Level 3: ICs (3×4 grid) -└── Level 4: Modules (2×3 grid) -``` - -**Workshop:** -``` -Muse Module (Fastener Organizer) -├── Level 1: Metric screws (8×10 grid) -├── Level 2: Metric bolts (6×8 grid) -├── Level 3: Imperial screws (8×10 grid) -└── Level 4: Imperial bolts (6×8 grid) -``` - -**Maker Space:** -``` -Apollo Module (Tool Storage) -├── Level 1: Hand tools -├── Level 2: Power tools -└── Level 3: Measuring instruments -``` - ---- - -## 💡 Key Concepts - -### Modules -Physical storage units (cabinets, shelving, toolboxes). Name them memorably! - -**Examples:** -- Zeus (Greek god theme) -- Cabinet-1 (Simple numbering) -- Electronics-Main (Descriptive) - -### Levels -Drawers, shelves, or compartments within modules. Each has a row×column grid. - -**Example:** -- Level 1: 4 rows × 6 columns = 24 storage bins - -### Locations -Individual storage positions (bins, compartments). Auto-created from grid. - -**Addressing:** `Module:Level:RowCol` -- `Zeus:1:A3` = Module "Zeus", Level 1, Location A3 - -### Items -Your actual inventory with natural language descriptions. - -**Good Description:** -> "Hex head bolt, M6 diameter, 50mm long, zinc plated, metric coarse thread" - -**Bad Description:** -> "Bolt" - -### Tags -Comma-separated keywords for better searching. - -**Example:** -> `bolt, metric, m6, hex, zinc, fastener` - ---- - -## 🔧 Common Workflows - -### First-Time Setup -1. Create your first module (e.g., "Zeus") -2. Add levels to the module (e.g., 3 drawers) -3. Define grid for each level (e.g., 4×6) -4. Start adding items! - -### Adding an Item -1. Items → Add Item -2. Name: "M6 Bolts" -3. Description: Natural language (be detailed!) -4. Category: "Fasteners" -5. Location: "Zeus:1:A3" -6. Quantity: 100 -7. Tags: "bolt, m6, metric, hex" -8. Create! - -### Finding an Item -**Option 1:** Search -- Search → Type "M6" -- Click result - -**Option 2:** Browse -- Modules → Zeus -- Level 1 -- Location A3 -- See all items - -### Moving Items -1. Find item -2. Edit -3. Update location -4. Adjust quantities -5. Save - ---- - -## 📊 Sample Data - -Want to see it in action immediately? - -```bash -python3 create_sample_data.py -``` - -Creates: -- 3 modules (Zeus, Muse, Apollo) -- Multiple levels per module -- 10 realistic items -- Various categories - -Perfect for testing and learning! - ---- - -## 🌐 Deployment Options - -### Option 1: Local Development -Perfect for testing. -```bash -cd inventory-system -docker-compose up -d -``` -Access: `http://localhost:8080` - -### Option 2: VPS/Cloud -Deploy to DigitalOcean, AWS, etc. -```bash -# Install Docker on VPS -# Copy inventory-system folder -docker-compose up -d -``` -Access: `http://your-server-ip:8080` - -### Option 3: Proxmox LXC -Great for homelabs. -- Create Ubuntu container -- Install Docker -- Deploy system - -### Option 4: Jetson Nano -For edge AI (Phase 4+). -- Docker pre-installed -- GPU support for AI -- Deploy normally - ---- - -## 🔐 Security - -### Default Settings (Development) -- ⚠️ Default PostgreSQL password -- ⚠️ No HTTPS -- ⚠️ No authentication -- ⚠️ All ports exposed - -**Fine for:** Local/home network use - -### Production Hardening (Do This!) -- ✅ Change database password -- ✅ Set secure SECRET_KEY -- ✅ Enable HTTPS -- ✅ Configure firewall -- ✅ Restrict port access -- ✅ Regular backups - -See `DEPLOY.md` for details. - ---- - -## 💾 Backup & Recovery - -### Quick Backup -```bash -# Database -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql - -# Everything -tar -czf backup.tar.gz data/ -``` - -### Quick Restore -```bash -docker-compose exec -T postgres psql -U inventoryuser inventory < backup.sql -``` - -### Automated Backups -Set up cron job for daily backups (see `DEPLOY.md`). - ---- - -## 🐛 Troubleshooting - -### Common Issues - -**Problem:** Can't access UI -**Fix:** `docker-compose logs nginx` - -**Problem:** Items not saving -**Fix:** `docker-compose logs backend` - -**Problem:** Port in use -**Fix:** Change port in `docker-compose.yml` - -**Problem:** Database connection failed -**Fix:** `docker-compose restart postgres` - -See `QUICK_REFERENCE.md` for full troubleshooting guide. - ---- - -## 📈 Performance - -### Current Capacity (Phase 1) -- **Items**: 10,000+ easily -- **Concurrent Users**: 1-5 recommended -- **Storage**: ~100MB per 1000 items -- **Search**: Fast (< 1 second) - -### Optimization (Phase 7+) -- Redis caching -- Database indexing -- Background jobs -- CDN for assets - ---- - -## 🤝 Support & Community - -### Getting Help -1. Check `QUICK_REFERENCE.md` -2. Review `README.md` -3. Check logs: `docker-compose logs` -4. Try sample data -5. Reset system: `docker-compose down -v && docker-compose up -d` - -### Reporting Issues -Include: -- Phase version (currently Phase 1) -- Error messages -- Steps to reproduce -- Docker logs - -### Feature Requests -Check `ROADMAP.md` first - it might be planned! - ---- - -## 📝 Version Information - -### Current Release -- **Version**: 1.0.0 -- **Phase**: 1 (Foundation) -- **Status**: ✅ Production Ready -- **Date**: October 2024 - -### Compatibility -- **Docker**: 20.10+ -- **Docker Compose**: 1.29+ -- **PostgreSQL**: 15 -- **Python**: 3.11+ -- **Browsers**: Chrome, Firefox, Safari, Edge (latest) - ---- - -## 🎓 Learning Path - -### Beginner -1. Deploy system (5 minutes) -2. Load sample data -3. Browse through modules -4. Try searching -5. Add your first item - -### Intermediate -1. Create your storage modules -2. Add 50-100 real items -3. Organize by category -4. Use tags effectively -5. Set up backups - -### Advanced -1. Use API endpoints -2. Write custom scripts -3. Optimize location layout -4. Plan for Phase 2+ -5. Customize system - ---- - -## 🏁 Success Checklist - -You'll know Phase 1 is working when: - -- [x] System starts without errors -- [x] Web UI is accessible -- [x] Can create modules and levels -- [x] Can add and find items -- [x] Search returns correct results -- [x] Location grid displays -- [x] Items can be in multiple locations -- [x] Data persists after restart -- [x] Backup/restore works -- [x] Sample data loads successfully - ---- - -## 🎯 Next Steps - -### Immediate (Today) -1. ✅ Extract package -2. ✅ Run `docker-compose up -d` -3. ✅ Access `http://localhost:8080` -4. ✅ Load sample data -5. ✅ Explore the system - -### This Week -1. ✅ Create your storage modules -2. ✅ Add 20-50 real items -3. ✅ Test search functionality -4. ✅ Set up daily backups -5. ✅ Read full documentation - -### This Month -1. ✅ Add majority of inventory -2. ✅ Refine organization -3. ✅ Provide feedback -4. ✅ Plan Phase 2 needs -5. ✅ Enjoy organized storage! - ---- - -## 💪 What Makes This Special? - -### Compared to Other Solutions - -**vs. Spreadsheets:** -- ✅ Better organization -- ✅ Faster search -- ✅ Location visualization -- ✅ Relationship tracking -- ✅ Future AI capabilities - -**vs. Commercial Systems:** -- ✅ Self-hosted (your data) -- ✅ Unlimited items -- ✅ No subscription fees -- ✅ Customizable -- ✅ Privacy-focused - -**vs. Basic Database:** -- ✅ User-friendly UI -- ✅ Storage hierarchy built-in -- ✅ Natural language support -- ✅ Easy deployment -- ✅ Regular backups - ---- - -## 🔬 Technical Highlights - -### Architecture -- **Design Pattern**: MVC (Model-View-Controller) -- **Database**: Relational (PostgreSQL) -- **ORM**: SQLAlchemy (prevents SQL injection) -- **Frontend**: Server-side rendering (fast, simple) -- **Deployment**: Containerized (portable) - -### Code Quality -- ✅ Proper foreign keys -- ✅ Cascade deletes -- ✅ Unique constraints -- ✅ Proper indexes -- ✅ Clean models -- ✅ RESTful API - -### Extensibility -Ready for: -- AI integration (Phase 4) -- Voice control (Phase 6) -- Mobile apps (Phase 8) -- Custom features -- Third-party integrations - ---- - -## 📞 Contact & Support - -### Documentation -All docs included in this package: -- README.md -- QUICKSTART.md -- DEPLOY.md -- ROADMAP.md -- QUICK_REFERENCE.md -- TESTING_CHECKLIST.md - -### Community -- Open issues for bugs -- Suggest features -- Share your setup -- Help others - -### Professional Support -For commercial deployments or customization, contact information would go here. - ---- - -## 📜 License - -[Your license here - recommend MIT or GPL] - ---- - -## 🙏 Credits - -Built with: -- Flask (Web framework) -- PostgreSQL (Database) -- SQLAlchemy (ORM) -- Docker (Containerization) -- nginx (Web server) - -Inspired by: -- Real homelab needs -- Maker community -- Electronic hobbyists -- Workshop organization challenges - ---- - -## 🌟 Final Words - -This inventory system is designed to grow with you: -- **Phase 1 (Now)**: Solid foundation -- **Phase 4**: AI-powered search -- **Phase 6**: Voice control -- **Phase 8**: Professional features - -But even Phase 1 is **fully usable** for real inventory management! - -**Start simple, expand as needed.** - ---- - -## Quick Links - -- [5-Minute Start](QUICKSTART.md) -- [Full Documentation](README.md) -- [Deployment Guide](DEPLOY.md) -- [Complete Roadmap](ROADMAP.md) -- [Quick Reference](QUICK_REFERENCE.md) -- [Testing Guide](TESTING_CHECKLIST.md) - ---- - -## Ready to Deploy? - -```bash -cd inventory-system -docker-compose up -d -# Wait 30 seconds -open http://localhost:8080 -# Start organizing! 🎉 -``` - ---- - -**Happy organizing! Your homelab will never be the same.** 🏠🔧📦✨ - -*Version 1.0.0 - Phase 1 Complete - October 2024* diff --git a/inventory-system/docs/PROJECT_SUMMARY.md b/inventory-system/docs/PROJECT_SUMMARY.md deleted file mode 100644 index 0da2fc1..0000000 --- a/inventory-system/docs/PROJECT_SUMMARY.md +++ /dev/null @@ -1,486 +0,0 @@ -# 🎉 Project Complete: Homelab Inventory System - Phase 1 - -## What Has Been Built - -I've created a **complete, production-ready Phase 1** inventory management system for your homelab/makerspace. The system is fully functional, tested, and ready to deploy. - -### 📊 Project Statistics - -- **Total Files**: 30+ source files -- **Lines of Code**: ~5,000+ lines -- **Project Size**: 147KB (excluding Docker images) -- **Time to Deploy**: 3 minutes -- **Documentation**: 4 comprehensive guides - -### ✅ Delivered Features - -#### Core Functionality -- ✅ **Complete storage hierarchy**: Modules → Levels → Locations -- ✅ **Full CRUD operations**: Create, Read, Update, Delete for all entities -- ✅ **Web interface**: Clean, responsive, mobile-friendly UI -- ✅ **Search system**: Keyword-based search across all fields -- ✅ **Visual location grids**: Interactive row/column displays -- ✅ **RESTful API**: JSON endpoints for programmatic access -- ✅ **PostgreSQL backend**: Properly normalized database schema -- ✅ **Docker deployment**: One-command deployment with Docker Compose - -#### Database Schema -- ✅ 5 core tables with proper relationships -- ✅ Foreign key constraints with cascade deletes -- ✅ Unique constraints preventing duplicates -- ✅ JSON metadata fields for flexibility -- ✅ Timestamp tracking for all records - -#### User Interface -- ✅ Dashboard with statistics -- ✅ Module management (add/edit/delete/view) -- ✅ Level management with grid configuration -- ✅ Location management with types and dimensions -- ✅ Item management with categories and tags -- ✅ Search interface with filtering -- ✅ Responsive design (desktop/tablet/mobile) - -#### Technical Features -- ✅ SQLAlchemy ORM with relationships -- ✅ Flask blueprints for modular routes -- ✅ Jinja2 templating with inheritance -- ✅ Custom CSS with design system -- ✅ Client-side JavaScript for interactivity -- ✅ nginx reverse proxy -- ✅ Environment variable configuration - -### 📁 Project Structure - -``` -inventory-system/ -├── README.md # 400+ line comprehensive guide -├── QUICKSTART.md # 5-minute deployment guide -├── DEPLOYMENT_SUMMARY.md # What's included & next steps -├── ARCHITECTURE.md # Technical deep dive -├── docker-compose.yml # Multi-container orchestration -├── nginx.conf # Reverse proxy config -├── .env.example # Configuration template -├── .gitignore # Version control exclusions -├── create_sample_data.py # Demo data generator -│ -├── backend/ # Python Flask application -│ ├── app/ -│ │ ├── __init__.py # App factory pattern -│ │ ├── models.py # 5 database models, 300+ lines -│ │ └── routes/ # Modular route handlers -│ │ ├── main.py # Dashboard routes -│ │ ├── items.py # Item CRUD + API -│ │ ├── modules.py # Module & level management -│ │ ├── locations.py # Location management -│ │ └── search.py # Search functionality -│ ├── requirements.txt # Python dependencies -│ ├── Dockerfile # Backend container -│ └── run.py # Application runner -│ -└── frontend/ # Web interface - ├── templates/ - │ ├── base.html # Base template with nav - │ ├── index.html # Dashboard - │ ├── modules/ - │ │ ├── list.html # Module listing - │ │ ├── view.html # Module details - │ │ └── form.html # Add/edit module - │ ├── levels/ - │ │ ├── view.html # Level grid view - │ │ └── form.html # Add/edit level - │ ├── items/ - │ │ ├── list.html # Item listing - │ │ ├── view.html # Item details - │ │ └── form.html # Add/edit item - │ ├── locations/ - │ │ ├── list.html # Location listing - │ │ ├── view.html # Location details - │ │ └── form.html # Edit location - │ └── search/ - │ └── results.html # Search results - └── static/ - ├── css/ - │ └── style.css # 1000+ lines, complete styling - └── js/ - └── main.js # Client-side logic -``` - -## 🚀 How to Use This Project - -### Immediate Steps - -1. **Extract the project** - ```bash - # The project is in: inventory-system/ - cd inventory-system - ``` - -2. **Read the documentation** - - Start with `QUICKSTART.md` (5-minute guide) - - Review `README.md` for complete documentation - - Check `DEPLOYMENT_SUMMARY.md` for overview - -3. **Deploy the system** - ```bash - docker-compose up -d - ``` - -4. **Access the web interface** - - Open browser: http://localhost:8080 - - Create your first module - - Add some items - - Test the search - -5. **Optional: Load sample data** - ```bash - pip install requests - python create_sample_data.py - ``` - -### What You Can Do Right Now - -#### Organize Your Workshop -1. Create modules for each physical storage unit -2. Add levels (drawers, shelves) to each module -3. Configure grid layouts (rows × columns) -4. Start adding your inventory items -5. Assign items to specific locations - -#### Example Workflow -``` -Create Module "Zeus" - → Add Level 1 (4×6 grid = 24 locations) - → Add Level 2 (3×4 grid = 12 locations) - → Total: 36 storage locations - -Add Items: - → "M6 Bolts" at Zeus:1:A3 - → "Resistors 1kΩ" at Zeus:1:B2 - → "Arduino Uno" at Zeus:2:A1 - -Search: - → Type "M6" → Find bolts at Zeus:1:A3 - → Type "arduino" → Find board at Zeus:2:A1 -``` - -## 📚 Documentation Provided - -### 1. README.md (Complete Guide) -- Prerequisites & installation -- Usage guide with examples -- Database schema documentation -- API endpoint reference -- Troubleshooting guide -- Backup/restore procedures -- Development setup -- Known limitations - -### 2. QUICKSTART.md (5-Minute Setup) -- Rapid deployment steps -- First-time setup walkthrough -- Common tasks guide -- Troubleshooting basics -- Access from other devices - -### 3. DEPLOYMENT_SUMMARY.md (Overview) -- What's included -- Quick start (3 steps) -- Real-world usage example -- Future phases roadmap -- Customization options -- Key features summary - -### 4. ARCHITECTURE.md (Technical Deep Dive) -- System architecture diagram -- Technology stack details -- Database schema with SQL -- API endpoint documentation -- Data flow diagrams -- Security considerations -- Performance characteristics -- Scalability path -- Development workflow - -## 🎯 What Makes This Special - -### Production Quality -- **Professional code structure**: Modular, maintainable, extensible -- **Proper database design**: Normalized schema with relationships -- **Complete error handling**: Flash messages, validation, constraints -- **Responsive UI**: Works on desktop, tablet, and mobile -- **Docker deployment**: Consistent across all platforms -- **Comprehensive docs**: Everything you need to know - -### Real-World Ready -- **Tested hierarchy**: Modules → Levels → Locations (proven structure) -- **Flexible storage**: Different location types for different items -- **Tag system**: Multiple ways to categorize and find items -- **Natural descriptions**: Store items as you describe them -- **Visual grids**: See your storage layout at a glance -- **Quick search**: Find items instantly by any keyword - -### Built for Growth -- **Phase 1 foundation**: Solid base for future features -- **Clean architecture**: Easy to add AI features later -- **API-first design**: CLI and voice can plug in easily -- **Extensible models**: JSON metadata for custom fields -- **Modular routes**: Add new features without breaking existing - -## 🔮 Future Roadmap - -### Phase 2: Smart Locations (Week 3) -- System suggests where to put items -- Location constraint checking -- Visual location picker - -### Phase 3: Duplicate Detection (Week 4) -- Warns about similar items -- Fuzzy matching algorithm -- Merge suggestions - -### Phase 4: AI Search (Week 5-6) -- Natural language queries -- Semantic similarity matching -- "Find me a long metric bolt" works - -### Phase 5: CLI (Week 7) -- Command-line interface -- Batch operations -- Power user features - -### Phase 6: Voice (Week 8-9) -- Wake word activation -- Voice queries -- Hands-free operation - -### Phase 7: Advanced AI (Week 10-11) -- Usage analytics -- Smart reorganization -- Alternative suggestions - -### Phase 8: Production (Week 12+) -- Multi-user support -- Mobile optimization -- QR codes & barcodes - -## 💡 Key Insights from Design - -### Storage Hierarchy -The three-level hierarchy (Module → Level → Location) perfectly matches physical storage: -- **Modules**: Cabinets, shelving units, storage areas -- **Levels**: Drawers, shelves, compartments -- **Locations**: Individual bins with row/col addresses - -This maps naturally to how people organize workshops. - -### Flexible Descriptions -Using natural language descriptions instead of rigid fields: -- Users describe items as they think about them -- No need to learn a specific format -- Tags provide additional structure -- Future AI will understand these descriptions - -### Location Types -Different storage needs different container types: -- Small boxes for SMD components -- Medium bins for fasteners -- Large bins for bulk items -- Liquid containers for paints -- This flexibility is key for real-world use - -### Many-to-Many Relationships -Items can be in multiple locations: -- Split quantities across bins -- Track partially used items -- Move items without losing history -- Essential for real inventory management - -## 🛠️ Technical Achievements - -### Database Design -- Properly normalized (3NF) -- Foreign keys with cascade -- Unique constraints prevent duplicates -- JSON fields for extensibility -- Timestamps for audit trail - -### Backend Architecture -- Flask blueprints for modularity -- SQLAlchemy ORM for type safety -- Separation of concerns (routes/models/services) -- RESTful API alongside web UI -- Environment-based configuration - -### Frontend Design -- Semantic HTML5 -- CSS custom properties (design system) -- Responsive grid layouts -- Progressive enhancement -- Accessible (WCAG considerations) - -### DevOps -- Docker multi-stage builds -- Compose for orchestration -- Volume mounts for persistence -- nginx for production-ready serving -- Environment variable configuration - -## 📊 Performance Characteristics - -### Current Capacity -- **Items**: Tested with 10,000+ items -- **Locations**: 1,000+ per level -- **Modules**: Unlimited -- **Search**: Sub-second for 10k items -- **Concurrent users**: 1-5 recommended - -### Resource Usage -- **RAM**: ~500MB total (all containers) -- **Disk**: ~147KB code + PostgreSQL data -- **CPU**: Minimal (Flask development server) -- **Network**: Local only (can expose) - -### Future Scaling -- Add indexing for 100k+ items -- Redis caching for heavy load -- Gunicorn workers for concurrency -- Read replicas for search queries - -## 🔐 Security Status - -### Current (Development) -- ✅ SQL injection protection (ORM) -- ✅ Input validation -- ⚠️ No authentication (single-user) -- ⚠️ HTTP only (no TLS) -- ⚠️ Default passwords - -### Production Checklist -- [ ] Change PostgreSQL password -- [ ] Set secure SECRET_KEY -- [ ] Enable HTTPS -- [ ] Add authentication -- [ ] Configure firewall -- [ ] Set up backups -- [ ] Enable audit logging - -## 🎓 Learning Resources Included - -### For Users -- QUICKSTART.md: Get running in 5 minutes -- README.md: Complete user guide -- Sample data script: See it in action -- In-app examples: Module/item creation - -### For Developers -- ARCHITECTURE.md: System design -- Code comments: Every file documented -- Modular structure: Easy to understand -- API examples: Integration guidance - -### For Deployers -- Docker Compose: Production-ready -- Environment variables: Easy config -- Backup scripts: Data protection -- Troubleshooting: Common issues solved - -## ✨ What Makes This Production-Ready - -1. **Complete Feature Set**: Everything needed for Phase 1 -2. **Professional Code**: Clean, documented, maintainable -3. **Comprehensive Docs**: 4 guides covering all aspects -4. **Docker Deployment**: Works everywhere, consistently -5. **Database Design**: Proper schema with relationships -6. **Error Handling**: User-friendly messages -7. **Responsive UI**: Works on all devices -8. **RESTful API**: Ready for integration -9. **Sample Data**: Quick demonstration -10. **Future-Proof**: Ready for AI features - -## 🚦 Next Steps - -### Immediate (Today) -1. ✅ Extract and review the project -2. ✅ Read QUICKSTART.md -3. ✅ Deploy with docker-compose -4. ✅ Load sample data -5. ✅ Explore the interface - -### Short-Term (This Week) -1. ✅ Add your real storage modules -2. ✅ Configure levels and grids -3. ✅ Start adding inventory items -4. ✅ Test search functionality -5. ✅ Customize location types - -### Medium-Term (Next Week) -1. 🔄 Add 50-100 real items -2. 🔄 Refine your organization -3. 🔄 Use it daily in your workflow -4. 🔄 Identify pain points -5. 🔄 Prepare for Phase 2 - -### Long-Term (Next Month) -1. 🔮 Deploy Phase 2 (smart locations) -2. 🔮 Add Phase 3 (duplicate detection) -3. 🔮 Enable Phase 4 (AI search) -4. 🔮 Build Phase 5 (CLI) -5. 🔮 Implement Phase 6 (voice) - -## 🎉 Success Metrics - -You'll know the system is working when: -- ✅ You can find any item in seconds -- ✅ You know exactly where everything is stored -- ✅ You stop buying duplicate parts -- ✅ Your workshop feels organized -- ✅ You save time on projects - -## 📞 Support Resources - -### Documentation -- README.md: Complete reference -- QUICKSTART.md: Quick help -- ARCHITECTURE.md: Technical details -- Code comments: Implementation notes - -### Troubleshooting -- Check Docker logs: `docker-compose logs` -- Verify containers: `docker-compose ps` -- Restart services: `docker-compose restart` -- Reset database: `docker-compose down -v` - -### Community -- Open issues on GitHub -- Share your setup -- Contribute improvements -- Request features - -## 🏆 Project Summary - -**What**: Complete inventory management system -**For**: Homelab/makerspace environments -**Features**: Storage hierarchy, search, web UI, API -**Phase**: 1 of 8 (Foundation - Complete) -**Status**: Production-ready, fully tested -**Documentation**: Comprehensive (4 guides) -**Deployment**: One command with Docker -**Next**: Use it, then add AI features - ---- - -## 🎯 Bottom Line - -You now have a **professional, production-ready inventory system** that: -- Works out of the box -- Handles thousands of items -- Provides web and API access -- Includes complete documentation -- Ready for AI features -- Deployable anywhere - -**Start organizing your workshop today!** 🛠️ - -The foundation is solid. The future phases will make it even more powerful with AI-powered search, voice control, and smart features. But right now, you have everything you need to manage your inventory effectively. - -**Happy organizing!** 🎉 diff --git a/inventory-system/docs/QUICKSTART.md b/inventory-system/docs/QUICKSTART.md deleted file mode 100644 index 06f499f..0000000 --- a/inventory-system/docs/QUICKSTART.md +++ /dev/null @@ -1,279 +0,0 @@ -# Quick Deployment Guide - -## Prerequisites Check - -Before starting, ensure you have: -- [ ] Docker installed (`docker --version`) -- [ ] Docker Compose installed (`docker-compose --version`) -- [ ] At least 2GB free RAM -- [ ] At least 10GB free disk space -- [ ] Ports 8080, 5432, and 5000 available - -## 5-Minute Deployment - -### Step 1: Navigate to Project Directory -```bash -cd inventory-system -``` - -### Step 2: Start the System -```bash -docker-compose up -d -``` - -Wait for containers to start (usually 30-60 seconds). - -### Step 3: Verify Deployment -```bash -docker-compose ps -``` - -You should see three containers running: -- `inventory-db` (postgres) -- `inventory-backend` (flask) -- `inventory-nginx` (nginx) - -### Step 4: Access the Application - -Open your web browser and go to: -``` -http://localhost:8080 -``` - -You should see the Inventory System dashboard! - -## First-Time Setup - -### 1. Create Your First Module - -Click "Modules" → "Add Module" - -Example: -- **Name**: Zeus -- **Description**: Main component storage cabinet -- **Physical Location**: North wall, workshop - -Click "Create Module" - -### 2. Add Levels to Your Module - -Click on your module name → "Add Level" - -Example: -- **Level Number**: 1 -- **Name**: Top Drawer -- **Rows**: 4 -- **Columns**: 6 -- **Description**: Small components and fasteners - -Click "Create Level" - -This creates a 4×6 grid of locations (A1-A6, B1-B6, C1-C6, D1-D6) - -### 3. Add Your First Item - -Click "Items" → "Add Item" - -Example: -- **Name**: M6 Bolts -- **Description**: Hex head bolt, M6 diameter, 50mm long, zinc plated, metric -- **Category**: Fasteners -- **Item Type**: solid -- **Quantity**: 100 -- **Unit**: pieces -- **Tags**: bolt, metric, m6, hex, zinc -- **Location**: Zeus:1:A1 - -Click "Create Item" - -### 4. Test Search - -Click "Search" and type "M6" or "bolt" - -You should see your item in the results! - -## Common Tasks - -### Viewing Storage Hierarchy - -1. Click "Modules" to see all storage units -2. Click a module name to see its levels -3. Click a level to see the location grid -4. Click a location to see what's stored there - -### Adding Items to Existing Locations - -1. Click "Items" → "Add Item" -2. Fill in item details -3. Select location from dropdown -4. Submit - -Or: - -1. Find the item you want to update -2. Click "Edit" on the item page -3. Add a new location - -### Searching for Items - -1. Click "Search" in the navigation -2. Type keywords (name, description, tags) -3. View results with locations - -### Editing Location Properties - -1. Navigate to the location (Modules → Module → Level → Location) -2. Click "Edit" -3. Set location type (small_box, medium_bin, etc.) -4. Add dimensions if needed -5. Save - -## Stopping the System - -### Temporary Stop (keeps data) -```bash -docker-compose stop -``` - -### Start Again -```bash -docker-compose start -``` - -### Complete Shutdown (keeps data) -```bash -docker-compose down -``` - -### Nuclear Option (deletes ALL data) -```bash -docker-compose down -v -``` - -⚠️ **Warning**: The `-v` flag deletes the database volume. Only use if you want to start fresh! - -## Troubleshooting - -### Container Won't Start - -```bash -# Check logs -docker-compose logs backend - -# Common issue: Port already in use -# Solution: Change port in docker-compose.yml or stop conflicting service -``` - -### Can't Connect to Database - -```bash -# Check if PostgreSQL is healthy -docker-compose ps - -# Restart PostgreSQL -docker-compose restart postgres - -# Check PostgreSQL logs -docker-compose logs postgres -``` - -### Web UI Not Loading - -```bash -# Check if nginx is running -docker-compose ps - -# Check nginx logs -docker-compose logs nginx - -# Restart nginx -docker-compose restart nginx -``` - -### Port 8080 Already in Use - -Edit `docker-compose.yml` and change the nginx port: -```yaml -nginx: - ports: - - "8081:80" # Changed from 8080 to 8081 -``` - -Then restart: -```bash -docker-compose down -docker-compose up -d -``` - -## Accessing from Other Devices - -To access from other computers on your network: - -1. Find your server's IP address: - ```bash - hostname -I - ``` - -2. On other devices, open browser to: - ``` - http://YOUR_SERVER_IP:8080 - ``` - -Example: `http://192.168.1.100:8080` - -## Backup Your Data - -### Quick Backup -```bash -# Backup database -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql - -# Backup everything -tar -czf inventory-backup-$(date +%Y%m%d).tar.gz data/ -``` - -### Restore from Backup -```bash -# Restore database -docker-compose exec -T postgres psql -U inventoryuser inventory < backup.sql -``` - -## Performance Tips - -### For Better Performance: - -1. **Add more RAM** to Docker (Docker Desktop → Resources) -2. **Use SSD** for the data directory -3. **Limit concurrent users** (single-user recommended for Phase 1) - -## Security Notes - -⚠️ **Important for Production**: - -1. Change default PostgreSQL password in `docker-compose.yml` -2. Set a secure SECRET_KEY in environment variables -3. Don't expose PostgreSQL port (5432) to the network -4. Use HTTPS with a reverse proxy -5. Set up regular backups - -## Getting Help - -If you encounter issues: - -1. Check the logs: `docker-compose logs` -2. Verify all containers are running: `docker-compose ps` -3. Try restarting: `docker-compose restart` -4. Check the main README.md for detailed documentation -5. Open an issue on GitHub with error logs - -## Next Steps - -Once you're comfortable with the basics: - -1. ✅ Add more modules for different storage areas -2. ✅ Organize items into categories -3. ✅ Use tags for better searchability -4. ✅ Set up location types for different bin sizes -5. ✅ Experiment with the grid layout for different storage configs - -Enjoy your new inventory system! 🎉 diff --git a/inventory-system/docs/QUICK_REFERENCE.md b/inventory-system/docs/QUICK_REFERENCE.md deleted file mode 100644 index e257b35..0000000 --- a/inventory-system/docs/QUICK_REFERENCE.md +++ /dev/null @@ -1,534 +0,0 @@ -# 📋 Quick Reference Card - -## Essential Commands - -### Starting & Stopping -```bash -# Start system -docker-compose up -d - -# Stop system (keep data) -docker-compose stop - -# Restart system -docker-compose restart - -# Shutdown completely (keep data) -docker-compose down - -# Nuclear option (DELETE ALL DATA) -docker-compose down -v -``` - -### Status Checks -```bash -# Check if containers are running -docker-compose ps - -# View logs (all services) -docker-compose logs -f - -# View specific service logs -docker-compose logs -f backend -docker-compose logs -f postgres -docker-compose logs -f nginx -``` - -### Backup & Restore -```bash -# Backup database -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup_$(date +%Y%m%d).sql - -# Backup entire data directory -tar -czf backup_$(date +%Y%m%d).tar.gz data/ - -# Restore database -docker-compose exec -T postgres psql -U inventoryuser inventory < backup_20241026.sql -``` - -### Troubleshooting -```bash -# Restart a stuck container -docker-compose restart backend - -# Rebuild containers -docker-compose up --build -d - -# Check container health -docker-compose ps - -# Reset everything -docker-compose down -v && docker-compose up -d -``` - ---- - -## Web Interface - -### Access -- Local: `http://localhost:8080` -- Network: `http://YOUR_IP:8080` - -### Navigation -- **Dashboard** → Overview of inventory -- **Modules** → Manage storage units -- **Items** → Browse/add inventory -- **Search** → Find items - -### Adding Items Flow -1. Click "Items" → "Add Item" -2. Fill in name and description -3. Select category and type -4. Enter quantity -5. Choose/create location -6. Add tags (comma-separated) -7. Click "Create Item" - -### Location Format -``` -ModuleName:LevelNumber:RowCol - -Examples: -Zeus:1:A3 → Module "Zeus", Level 1, Location A3 -Muse:2:B5 → Module "Muse", Level 2, Location B5 -Apollo:3:C2 → Module "Apollo", Level 3, Location C2 -``` - ---- - -## Common Workflows - -### Workflow 1: New Storage Module -``` -1. Modules → Add Module - - Name: Zeus - - Description: Electronics cabinet - - Location: North wall - -2. Click module → Add Level - - Level: 1 - - Rows: 4 - - Columns: 6 - - Creates 24 locations (A1-D6) - -3. Repeat for other levels -``` - -### Workflow 2: Adding First Item -``` -1. Items → Add Item -2. Name: M6 Bolts -3. Description: Hex head bolt, M6 diameter, 50mm long, zinc plated -4. Category: Fasteners -5. Type: solid -6. Quantity: 100 -7. Unit: pieces -8. Location: Zeus:1:A3 -9. Tags: bolt, metric, m6, hex -10. Create Item -``` - -### Workflow 3: Finding Items -``` -Option A: Browse -1. Modules → Zeus -2. Level 1 -3. Click location (e.g., A3) -4. See all items in that location - -Option B: Search -1. Search → Type "M6" -2. View results -3. Click item for details & location -``` - -### Workflow 4: Moving Items -``` -1. Find item (search or browse) -2. Click item name -3. Click "Edit" -4. Change location -5. Update quantity at each location -6. Save -``` - -### Workflow 5: Organizing -``` -1. Create module for category (e.g., "Electronics") -2. Add levels for subcategories - - Level 1: Resistors - - Level 2: Capacitors - - Level 3: ICs -3. Set up grid for each level -4. Move items to appropriate locations -5. Use tags for cross-referencing -``` - ---- - -## Tips & Best Practices - -### Item Descriptions -✅ **Good:** -- "Pan head phillips screw, #8 size, 3/4 inch long, mild steel, zinc plated" -- "Ceramic capacitor, 0.1 microfarad, 0805 package, 50V rating, X7R dielectric" -- "M6 hex bolt, 50mm length, zinc plated, metric coarse thread" - -❌ **Bad:** -- "Screw" -- "Capacitor" -- "Bolt" - -### Tagging Strategy -``` -Use multiple tags for findability: -- Material: steel, aluminum, plastic, ceramic -- Size: m6, #8, 0805, 1/4-inch -- Type: screw, bolt, resistor, capacitor -- Finish: zinc, stainless, black-oxide -- Category: fastener, electronics, tool -``` - -### Module Naming -``` -✅ Descriptive & Memorable: -- Zeus (main electronics) -- Muse (fasteners) -- Apollo (tools) -- Workshop-Main -- Garage-Cabinet-1 - -❌ Generic: -- Cabinet1 -- Storage2 -- Box3 -``` - -### Level Organization -``` -Top to Bottom or Most to Least Used: - -Level 1: Frequently accessed items -Level 2: Moderate use -Level 3: Occasional use -Level 4: Rarely used / archive - -Or by size: -Level 1: Small components -Level 2: Medium parts -Level 3: Large items -Level 4: Bulk storage -``` - ---- - -## Location Types - -| Type | Best For | Examples | -|------|----------|----------| -| `small_box` | Tiny components | SMD parts, small screws | -| `medium_bin` | Standard parts | Resistors, bolts, LEDs | -| `large_bin` | Bigger items | Tools, wire spools | -| `liquid_container` | Liquids/chemicals | Paints, solvents, oils | -| `smd_container` | Surface-mount | 0402, 0603, 0805 parts | -| `bulk_bin` | Loose items | Zip ties, cable, wire | -| `tool_holder` | Tools | Screwdrivers, pliers | -| `general` | Default | Anything | - ---- - -## Item Types - -| Type | Description | Examples | -|------|-------------|----------| -| `solid` | Standard parts | Bolts, resistors, brackets | -| `liquid` | Liquids/coatings | Paint, glue, solvents | -| `smd_component` | Surface-mount | 0805 caps, SOT-23 transistors | -| `bulk` | Loose/bulk items | Wire, zip ties, sandpaper | -| `tool` | Tools/equipment | Screwdrivers, meters, crimpers | -| `consumable` | Used up over time | Solder, flux, tape | - ---- - -## API Quick Reference - -### List Items -```bash -curl http://localhost:8080/items/api/items -``` - -### Search Items -```bash -curl http://localhost:8080/items/api/items?search=M6 -``` - -### Get Item Details -```bash -curl http://localhost:8080/items/api/items/42 -``` - -### List Modules -```bash -curl http://localhost:8080/modules/api/modules -``` - -### List Locations -```bash -curl http://localhost:8080/locations/api/locations -``` - ---- - -## Keyboard Shortcuts (Web UI) - -| Key | Action | -|-----|--------| -| `/` | Focus search | -| `Ctrl+K` | Quick search | -| `Escape` | Close modals | - -*(More shortcuts coming in future phases)* - ---- - -## Maintenance Schedule - -### Daily -- Use the system! -- Add items as you acquire them - -### Weekly -- Review uncategorized items -- Update quantities as needed -- Check for low stock - -### Monthly -- Backup database -- Review organization -- Archive old logs (Phase 8) - -### Quarterly -- Deep clean/reorganize -- Review location efficiency -- Update documentation - ---- - -## When Things Go Wrong - -### Problem: Can't access web UI - -**Check:** -```bash -# Are containers running? -docker-compose ps - -# Check nginx logs -docker-compose logs nginx - -# Restart nginx -docker-compose restart nginx -``` - -### Problem: Items not saving - -**Check:** -```bash -# Check backend logs -docker-compose logs backend - -# Check database -docker-compose exec postgres psql -U inventoryuser inventory -c "SELECT COUNT(*) FROM items;" - -# Restart backend -docker-compose restart backend -``` - -### Problem: Search not working - -**Check:** -```bash -# Check backend logs -docker-compose logs backend - -# Try searching via API -curl "http://localhost:8080/items/api/items?search=test" -``` - -### Problem: Database connection failed - -**Fix:** -```bash -# Restart PostgreSQL -docker-compose restart postgres - -# Wait 10 seconds, then restart backend -sleep 10 -docker-compose restart backend -``` - -### Problem: Port already in use - -**Fix:** -Edit `docker-compose.yml`: -```yaml -nginx: - ports: - - "8081:80" # Change 8080 to 8081 -``` - -Then: -```bash -docker-compose down && docker-compose up -d -``` - -### Problem: Out of disk space - -**Fix:** -```bash -# Remove old Docker images -docker system prune -a - -# Remove old backups -rm old_backup_*.sql - -# Clean up logs -docker-compose logs --tail=100 > recent_logs.txt -# (Then manually clean up old logs) -``` - ---- - -## Performance Tips - -1. **Add more RAM** to Docker (Settings → Resources) -2. **Use SSD** for data directory -3. **Regular backups** prevent data loss -4. **Limit concurrent users** (Phase 1 is single-user optimized) -5. **Index frequently searched fields** (automatically done) - ---- - -## Security Checklist - -For production deployments: - -- [ ] Changed default PostgreSQL password -- [ ] Set secure `SECRET_KEY` -- [ ] PostgreSQL port not exposed to internet -- [ ] Using HTTPS (Let's Encrypt) -- [ ] Firewall configured -- [ ] Regular backups enabled -- [ ] Updates applied regularly -- [ ] Monitoring enabled (Phase 8) - ---- - -## Sample Data - -Want to test with realistic data? - -```bash -python3 create_sample_data.py -``` - -This adds: -- 3 modules (Zeus, Muse, Apollo) -- Multiple levels per module -- 10 sample items - -Delete sample data later: -```sql -docker-compose exec postgres psql -U inventoryuser inventory -DELETE FROM items WHERE name LIKE '%sample%'; -``` - ---- - -## Getting Help - -1. **Check logs:** `docker-compose logs` -2. **Review README.md** for detailed docs -3. **Check ROADMAP.md** for future features -4. **Try sample data** to verify setup -5. **Ask for help** (open issue, forum, etc.) - ---- - -## Phase 1 Limitations - -What's NOT included yet (coming in future phases): - -- ❌ AI semantic search (Phase 4) -- ❌ Duplicate detection (Phase 3) -- ❌ Location suggestions (Phase 2) -- ❌ CLI interface (Phase 5) -- ❌ Voice control (Phase 6) -- ❌ User authentication (Phase 8) -- ❌ Mobile app (Phase 8) -- ❌ Barcode scanning (Phase 8) - -But you CAN: -- ✅ Track unlimited items -- ✅ Organize in modules/levels/locations -- ✅ Search by keyword -- ✅ View location grids -- ✅ Export/backup data -- ✅ Access from any device on network - ---- - -## Next Steps - -1. **Deploy:** `docker-compose up -d` -2. **Create first module:** Your main storage unit -3. **Add 10-20 items:** Get familiar with the system -4. **Experiment:** Try different organizations -5. **Provide feedback:** What works? What's missing? - ---- - -## Emergency Recovery - -If everything breaks: - -```bash -# 1. Backup current database (if possible) -docker-compose exec postgres pg_dump -U inventoryuser inventory > emergency_backup.sql - -# 2. Stop everything -docker-compose down - -# 3. Fresh start (DELETES DATA) -docker-compose down -v -docker-compose up -d - -# 4. Restore from backup -docker-compose exec -T postgres psql -U inventoryuser inventory < emergency_backup.sql -``` - ---- - -## Version Info - -- **Version:** 1.0.0 (Phase 1) -- **Status:** Production Ready -- **Last Updated:** October 2024 - ---- - -**Print this card and keep it near your workstation!** 📌 - ---- - -## Quick URL Reference - -| Service | URL | -|---------|-----| -| Web UI | http://localhost:8080 | -| API Docs | http://localhost:8080/api (Phase 8) | -| Health Check | http://localhost:8080/health | - ---- - -**Happy organizing! 🏠🔧📦** diff --git a/inventory-system/docs/ROADMAP.md b/inventory-system/docs/ROADMAP.md deleted file mode 100644 index 287662f..0000000 --- a/inventory-system/docs/ROADMAP.md +++ /dev/null @@ -1,1065 +0,0 @@ -# 🗺️ Homelab Inventory System - Complete Roadmap - -## Overview - -This document outlines the complete 8-phase development plan. **Phase 1 is complete and deployable now.** Each subsequent phase builds on the previous ones, following the HIIL (Hardware-In-the-Loop) principle where you can deploy and test at each stage. - ---- - -## ✅ Phase 1: Foundation [COMPLETE] - -**Status:** ✅ Deployed and Ready -**Timeline:** Weeks 1-2 -**Complexity:** Basic - -### What's Working Now: - -- [x] Complete database schema with proper relationships -- [x] Docker deployment (PostgreSQL + Flask + nginx) -- [x] Storage hierarchy: Modules → Levels → Locations -- [x] Full CRUD operations via web UI -- [x] Items with natural language descriptions -- [x] Basic keyword search -- [x] Location grid visualization -- [x] Many-to-many item-location relationships -- [x] Quantity tracking -- [x] Category and tag support -- [x] Sample data generator - -### Technology Stack: -- Python 3.11+ with Flask -- PostgreSQL 15 -- SQLAlchemy ORM -- Jinja2 templates -- Docker Compose - -### Deliverables: -1. ✅ Working web application -2. ✅ Database with migrations -3. ✅ Docker deployment configuration -4. ✅ User documentation -5. ✅ Sample data for testing - -### Test Milestone: -✅ Add 50-100 items and navigate the storage hierarchy - ---- - -## 🔜 Phase 2: Smart Location Management - -**Status:** 🚧 Next Up -**Timeline:** Week 3 -**Complexity:** Intermediate - -### Goals: -- System suggests optimal storage locations -- Location compatibility checking -- Space utilization tracking -- Smart reorganization hints - -### Features to Build: - -1. **Location Profiles** - - Define location dimensions and types - - Set compatibility rules (e.g., no liquids in electronics drawer) - - Track occupied vs. available space - -2. **Location Suggestion Algorithm** - ```python - def suggest_location(item): - # Find empty locations matching item type - # Prioritize locations near similar items - # Consider accessibility and frequency of use - # Return ranked suggestions - ``` - -3. **Enhanced UI** - - "Suggest location" button when adding items - - Visual location map with availability heatmap - - Filter locations by type/size/availability - - Show capacity utilization per location - -4. **Location Types** - - Extend existing types: small_box, medium_bin, large_bin - - Add: liquid_container, smd_box, bulk_bin, tool_holder - - Custom dimensions for each type - - Visual indicators in UI - -### Implementation Plan: - -**Week 3, Day 1-2: Backend** -```bash -# Add to backend/app/services/location_suggestion.py -- LocationMatcher class -- CompatibilityChecker class -- SuggestionEngine class -``` - -**Week 3, Day 3-4: UI** -```bash -# Enhance templates -- Add suggestion button to item form -- Create location picker with suggestions -- Add location type configuration page -``` - -**Week 3, Day 5: Testing** -- Test with various item types -- Verify suggestions make sense -- Check edge cases (no available locations, etc.) - -### API Endpoints: -``` -GET /locations/api/suggest?item_type=liquid&size=large -POST /locations/api//configure (set dimensions, type) -GET /locations/api/availability -``` - -### Test Milestone: -Add 100 items using location suggestions, verify they make sense - -### Database Changes: -```sql --- Add to locations table -ALTER TABLE locations ADD COLUMN max_weight_kg FLOAT; -ALTER TABLE locations ADD COLUMN is_temperature_controlled BOOLEAN; -ALTER TABLE locations ADD COLUMN compatible_item_types TEXT[]; - --- Add utilization tracking -CREATE TABLE location_utilization ( - location_id INTEGER, - used_percentage FLOAT, - last_updated TIMESTAMP -); -``` - ---- - -## 🔜 Phase 3: Duplicate Detection - -**Status:** 📋 Planned -**Timeline:** Week 4 -**Complexity:** Intermediate - -### Goals: -- Detect when adding duplicate/similar items -- Parse common specifications automatically -- Warn before creating near-duplicates -- Suggest merging or consolidating - -### Features to Build: - -1. **Pattern Recognition** - - Parse bolt/screw specifications (M6, #8, etc.) - - Extract resistor values (1kΩ, 10k, etc.) - - Identify capacitor specifications - - Recognize standard tool sizes - -2. **Similarity Detection** - ```python - def find_similar_items(new_item_description): - # Extract key specifications - # Search existing items - # Calculate similarity scores - # Return potential duplicates with locations - ``` - -3. **Smart Warnings** - - Show similar items when adding new item - - Display differences between items - - Option to update existing item instead - - Suggest consolidating locations - -4. **Specification Extraction** - - Automatically populate metadata fields - - Standardize formats (e.g., "1/4 inch" → "6.35mm") - - Create searchable tags from specs - -### Implementation Plan: - -**Week 4, Day 1-2: Parser Library** -```python -# backend/app/services/spec_parser.py -class SpecificationParser: - def parse_fastener(description) - def parse_resistor(description) - def parse_capacitor(description) - def extract_dimensions(description) - def standardize_units(value, unit) -``` - -**Week 4, Day 3-4: Duplicate Detection** -```python -# backend/app/services/duplicate_detection.py -class DuplicateDetector: - def find_similar(description) - def calculate_similarity(item1, item2) - def suggest_merge(items) -``` - -**Week 4, Day 5: UI Integration** -- Add warning dialog when duplicates found -- Show comparison view -- Add "merge items" functionality - -### Regex Patterns to Implement: -```python -# Fasteners -r'M(\d+)(?:x(\d+))?' # M6x50 or M6 -r'#(\d+)(?:\s*x\s*([0-9/]+))?' # #8 x 3/4 -r'(\d+/\d+)\s*inch' # 3/4 inch - -# Electronics -r'(\d+\.?\d*)\s*([kMΩ]?Ω)' # 1kΩ, 10Ω -r'(\d+\.?\d*)\s*([μnp]?F)' # 0.1μF, 100nF -r'(\d{4})\s*(?:package)?' # 0805, 1206 - -# Tools -r'(\d+)\s*mm' # 10mm -r'(\d+/\d+)\s*inch' # 1/4 inch -``` - -### Test Milestone: -Add intentional duplicates and verify detection works - ---- - -## 🤖 Phase 4: Semantic Search Foundation - -**Status:** 📋 Planned -**Timeline:** Weeks 5-6 -**Complexity:** Advanced - -### Goals: -- Natural language queries work well -- Find items by concept, not just keywords -- "M6 metric bolt" finds "M6 hex head bolt, 50mm" -- Ranked results by relevance - -### Features to Build: - -1. **Embedding Generation** - ```python - from sentence_transformers import SentenceTransformer - - model = SentenceTransformer('all-MiniLM-L6-v2') - - def generate_embedding(description): - return model.encode(description) - ``` - -2. **PostgreSQL Setup** - ```sql - -- Install pgvector extension - CREATE EXTENSION IF NOT EXISTS vector; - - -- Add embedding column to items - ALTER TABLE items ADD COLUMN embedding vector(384); - - -- Create index for fast similarity search - CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops); - ``` - -3. **Semantic Search API** - ```python - def semantic_search(query, limit=10): - query_embedding = generate_embedding(query) - # Cosine similarity search - results = db.session.query(Item).order_by( - Item.embedding.cosine_distance(query_embedding) - ).limit(limit) - return results - ``` - -4. **Enhanced Search UI** - - Natural language search bar - - Results with relevance scores - - "Similar items" suggestions - - Search history - -### Implementation Plan: - -**Week 5, Day 1: Setup** -- Install sentence-transformers -- Add pgvector to PostgreSQL -- Test embedding generation - -**Week 5, Day 2-3: Batch Processing** -```python -# Generate embeddings for all existing items -def backfill_embeddings(): - items = Item.query.all() - for item in items: - embedding = generate_embedding(item.description) - item.embedding = embedding - db.session.commit() -``` - -**Week 5, Day 4-5: Search API** -```python -# backend/app/routes/search.py -@bp.route('/api/semantic-search') -def semantic_search(): - query = request.args.get('q') - results = semantic_search_service.search(query) - return jsonify([r.to_dict() for r in results]) -``` - -**Week 6, Day 1-3: UI Integration** -- Replace/enhance existing search -- Add relevance scores -- Show similar items sidebar - -**Week 6, Day 4-5: Testing & Tuning** -- Test with various queries -- Compare with keyword search -- Fine-tune similarity thresholds - -### Model Options: -```python -# Fast & efficient (default) -'all-MiniLM-L6-v2' # 384 dimensions, 120MB - -# Better accuracy -'all-mpnet-base-v2' # 768 dimensions, 420MB - -# Domain-specific (if we fine-tune later) -'custom-inventory-model' # Trained on our data -``` - -### Test Milestone: -Query "long metric bolt M6" and get relevant results ranked properly - ---- - -## 💻 Phase 5: CLI Interface - -**Status:** 📋 Planned -**Timeline:** Week 7 -**Complexity:** Intermediate - -### Goals: -- Terminal access for power users -- Scriptable inventory management -- Fast bulk operations -- Import/export capabilities - -### Features to Build: - -1. **Command-Line Tool: `invctl`** - ```bash - invctl add "M6 bolt, 50mm, zinc" --location Zeus:1:A3 - invctl search "resistor 1k" - invctl list --module Zeus --level 1 - invctl update item 42 --quantity 200 - invctl delete item 42 - invctl export --format csv > inventory.csv - invctl import inventory.csv - ``` - -2. **Interactive Mode** - ```bash - invctl shell - > add "New item description" - > search "bolt" - > exit - ``` - -3. **Batch Operations** - ```bash - # Import from CSV - invctl import fasteners.csv --module Muse --level 2 - - # Export filtered items - invctl export --category Electronics --format json - - # Bulk update - invctl bulk-update --tag resistor --field category --value "Electronics/Resistors" - ``` - -4. **Tab Completion** - ```bash - invctl add "..." --location - # Shows: Zeus, Muse, Apollo - - invctl add "..." --location Zeus: - # Shows: 1, 2, 3 - ``` - -### Implementation Plan: - -**Week 7, Day 1-2: Core CLI** -```python -# cli/invctl.py -import click -import requests - -@click.group() -def cli(): - """Inventory Control CLI""" - pass - -@cli.command() -@click.argument('description') -@click.option('--location') -@click.option('--quantity', default=1) -def add(description, location, quantity): - """Add a new item""" - # Parse location (Module:Level:RowCol) - # POST to API - # Show confirmation -``` - -**Week 7, Day 3: Interactive Shell** -```python -# Use prompt_toolkit for better UX -from prompt_toolkit import PromptSession -from prompt_toolkit.completion import WordCompleter - -def interactive_shell(): - session = PromptSession() - while True: - command = session.prompt('invctl> ') - # Parse and execute -``` - -**Week 7, Day 4: Import/Export** -```python -# CSV format -# name,description,category,quantity,location -# M6 Bolts,Hex head...,Fasteners,100,Zeus:1:A3 - -@cli.command() -@click.argument('file') -def import_csv(file): - # Read CSV - # Parse each line - # POST to API - # Show progress bar -``` - -**Week 7, Day 5: Packaging** -```bash -# Setup.py for easy installation -pip install -e . -# Now 'invctl' is in PATH -``` - -### CLI Structure: -``` -cli/ -├── invctl.py # Main CLI entry point -├── commands/ -│ ├── add.py -│ ├── search.py -│ ├── list.py -│ ├── import_export.py -│ └── interactive.py -├── utils/ -│ ├── api_client.py # Requests wrapper -│ ├── formatters.py # Pretty printing -│ └── validators.py # Input validation -└── setup.py -``` - -### Test Milestone: -Manage inventory from terminal only for a full day - ---- - -## 🎤 Phase 6: Voice Interface - -**Status:** 📋 Planned -**Timeline:** Weeks 8-9 -**Complexity:** Advanced - -### Goals: -- Hands-free operation in workshop -- Natural voice commands -- Offline speech recognition -- Voice confirmations - -### Features to Build: - -1. **Wake Word Detection** - ```python - import pvporcupine - - # Offline wake word: "Hey Inventory" - porcupine = pvporcupine.create( - keywords=['jarvis'] # Or custom trained - ) - ``` - -2. **Speech Recognition** - ```python - # Option 1: Vosk (offline, fast) - from vosk import Model, KaldiRecognizer - - # Option 2: Whisper (better accuracy) - import whisper - model = whisper.load_model("base") - ``` - -3. **Voice Commands** - ```python - # Example interactions: - "Hey Inventory" - > "Listening..." - - "Add new item: M6 bolt, 50 millimeters long, to Zeus level 2 position A3" - > "Adding M6 bolt, 50mm to Zeus:2:A3. Is this correct?" - - "Yes" - > "Item added successfully. Anything else?" - - "Find metric bolts" - > "I found 3 items: M6 bolts in Zeus:2:A3, M8 bolts in Muse:1:B2..." - ``` - -4. **Text-to-Speech Responses** - ```python - import pyttsx3 - - engine = pyttsx3.init() - engine.say("Item added successfully") - engine.runAndWait() - ``` - -### Implementation Plan: - -**Week 8, Day 1-2: Wake Word** -```python -# voice/wake_word.py -class WakeWordDetector: - def __init__(self): - self.porcupine = pvporcupine.create( - keywords=['jarvis'] - ) - - def listen(self): - # Listen for wake word - # Return True when detected -``` - -**Week 8, Day 3-4: Speech-to-Text** -```python -# voice/stt.py -class SpeechRecognizer: - def __init__(self): - self.model = vosk.Model("model") - - def transcribe(self, audio): - # Convert audio to text - return text -``` - -**Week 8, Day 5 - Week 9, Day 2: Command Parsing** -```python -# voice/parser.py -class VoiceCommandParser: - def parse(self, text): - # Identify intent (add, search, update, delete) - # Extract entities (item desc, location, quantity) - # Return structured command - - # Example: - # "Add M6 bolt to Zeus level 2 A3" - # → {'action': 'add', 'item': 'M6 bolt', - # 'location': 'Zeus:2:A3'} -``` - -**Week 9, Day 3-4: Integration** -```python -# voice/voice_interface.py -class VoiceInterface: - def __init__(self): - self.wake_word = WakeWordDetector() - self.stt = SpeechRecognizer() - self.tts = TextToSpeech() - self.parser = VoiceCommandParser() - self.api = APIClient() - - def run(self): - while True: - if self.wake_word.detected(): - self.process_command() -``` - -**Week 9, Day 5: Testing** -- Test in noisy workshop environment -- Verify accuracy with different accents -- Test edge cases (similar sounding words) - -### Hardware Options: - -**Option 1: Raspberry Pi Station** -- Raspberry Pi 4 (2GB+) -- USB microphone -- Speaker -- Runs voice interface as service -- Connects to main server API - -**Option 2: USB Microphone + Server** -- Connect mic to server running inventory system -- Voice interface runs on same machine - -**Option 3: Jetson Nano** -- Run everything on Jetson -- GPU acceleration for Whisper -- Better accuracy with AI models - -### Test Milestone: -Add and search for 20 items using only voice - ---- - -## 🧠 Phase 7: Advanced AI Features - -**Status:** 📋 Planned -**Timeline:** Weeks 10-11 -**Complexity:** Advanced - -### Goals: -- Smarter recommendations -- Predictive organization -- Usage pattern analysis -- Automated categorization - -### Features to Build: - -1. **Fine-Tuned Semantic Model** - ```python - # Train on your actual inventory descriptions - from sentence_transformers import SentenceTransformer, InputExample - - # Create training data from your inventory - train_examples = [ - InputExample(texts=['M6 bolt', 'M6 hex bolt'], label=0.9), - InputExample(texts=['M6 bolt', 'resistor'], label=0.1), - # ... more examples - ] - - # Fine-tune - model = SentenceTransformer('all-MiniLM-L6-v2') - model.fit(train_examples) - ``` - -2. **Smart Categorization** - ```python - def auto_categorize(item_description): - # Use zero-shot classification - from transformers import pipeline - - classifier = pipeline("zero-shot-classification") - categories = ["Electronics", "Fasteners", "Tools", - "Paints", "Hardware"] - result = classifier(item_description, categories) - return result['labels'][0] - ``` - -3. **Usage Analytics** - ```python - class UsageAnalyzer: - def frequently_accessed_items(self, days=30): - # Track item access frequency - # Suggest moving to more accessible location - - def low_stock_prediction(self): - # Analyze usage patterns - # Predict when items will run out - - def suggest_reorganization(self): - # Items often used together - # Should be stored near each other - ``` - -4. **Cross-Reference System** - ```python - def find_substitutes(item): - # Find alternative items - # M6 bolt → M6 screw (if bolt unavailable) - - def find_complementary(item): - # Arduino → jumper wires, breadboard - # M6 bolt → M6 nut, M6 washer - ``` - -5. **Natural Language Queries** - ```python - # Instead of: search "M6 bolt 50mm zinc" - # User asks: "I need a medium-length metric bolt, - # preferably zinc-coated, around 6mm diameter" - - class NLQueryProcessor: - def parse_nl_query(self, query): - # Extract intent and constraints - # Map to structured search - # Return ranked results - ``` - -### Implementation Plan: - -**Week 10, Day 1-2: Model Fine-Tuning** -- Collect training data from actual inventory -- Create positive/negative pairs -- Fine-tune embedding model -- Evaluate improvement - -**Week 10, Day 3-4: Auto-Categorization** -- Implement zero-shot classifier -- Test on existing items -- Add to item creation flow - -**Week 10, Day 5 - Week 11, Day 1: Usage Tracking** -```sql -CREATE TABLE item_access_log ( - item_id INTEGER, - accessed_at TIMESTAMP, - action TEXT -- 'viewed', 'updated', 'moved' -); - -CREATE TABLE usage_analytics ( - item_id INTEGER, - access_count_7d INTEGER, - access_count_30d INTEGER, - last_accessed TIMESTAMP -); -``` - -**Week 11, Day 2-3: Analytics Dashboard** -- Most accessed items -- Low stock warnings -- Reorganization suggestions -- Usage heatmaps - -**Week 11, Day 4-5: NL Query Processing** -- Implement query understanding -- Test with complex queries -- Compare with simple search - -### Test Milestone: -System makes useful organizational suggestions based on usage - ---- - -## 🏆 Phase 8: Production Polish - -**Status:** 📋 Planned -**Timeline:** Week 12+ -**Complexity:** Intermediate - -### Goals: -- Production-ready deployment -- Multi-user support (if needed) -- Mobile optimization -- Professional features - -### Features to Build: - -1. **Authentication & Multi-User** - ```python - from flask_login import LoginManager, login_required - - # Optional - only if needed - # Single-user mode by default - - @app.route('/items') - @login_required # If multi-user enabled - def items(): - pass - ``` - -2. **Mobile-Optimized UI** - - Responsive design (already decent) - - Touch-friendly buttons - - Swipe gestures - - Camera integration for barcode/QR scanning - -3. **QR Code System** - ```python - import qrcode - - def generate_location_qr(location): - # Generate QR code for location - # Scan to quickly add items to location - - def generate_item_qr(item): - # Generate QR code for item - # Scan to view item details - ``` - -4. **Barcode Scanning** - ```python - # Use Zebra Crossing (ZXing) or similar - # Scan UPC/EAN barcodes - # Look up item in database - # Or add new item with pre-filled info - ``` - -5. **Advanced Reports** - - Inventory value report - - Stock level report - - Usage statistics - - Location capacity report - - Export to Excel/PDF - -6. **Backup & Restore UI** - ```python - @app.route('/admin/backup') - def create_backup(): - # Trigger backup - # Download SQL file - - @app.route('/admin/restore', methods=['POST']) - def restore_backup(): - # Upload SQL file - # Restore database - ``` - -7. **Monitoring Dashboard** - ```python - # System health - # Database size - # Item count - # Container status - # Backup status - ``` - -8. **API Documentation** - - Swagger/OpenAPI spec - - Interactive API explorer - - Example requests - -### Production Checklist: - -- [ ] HTTPS with Let's Encrypt -- [ ] Strong passwords for all services -- [ ] Regular automated backups -- [ ] Monitoring and alerting -- [ ] Error logging (Sentry, etc.) -- [ ] Rate limiting -- [ ] Input validation -- [ ] SQL injection prevention (already handled by SQLAlchemy) -- [ ] XSS protection -- [ ] CSRF tokens -- [ ] Security headers -- [ ] Container updates (Watchtower, etc.) - -### Performance Optimization: - -- [ ] Redis caching for search results -- [ ] Database query optimization -- [ ] Image optimization (if adding photos) -- [ ] Lazy loading in UI -- [ ] Pagination for large lists -- [ ] Background job queue (Celery) - -### Mobile App Options: - -**Option 1: Progressive Web App (PWA)** -- Add manifest.json -- Service worker for offline support -- Install prompt - -**Option 2: React Native App** -- Native iOS/Android app -- Better performance -- Native barcode scanner - -**Option 3: Flutter App** -- Cross-platform -- Single codebase -- Native feel - -### Test Milestone: -System runs reliably 24/7 with no issues - ---- - -## Success Metrics - -### Phase 1 (Current): -- ✅ Can add 100+ items easily -- ✅ Search finds items quickly -- ✅ Location hierarchy is clear -- ✅ No crashes or data loss - -### Phase 2: -- Location suggestions make sense -- Reduced time finding storage spots -- Better space utilization - -### Phase 3: -- Duplicate detection catches 90%+ of duplicates -- False positive rate < 10% -- Automatic spec extraction works for common items - -### Phase 4: -- Semantic search returns relevant results -- Natural queries work ("medium bolt") -- Better than keyword search - -### Phase 5: -- Can manage inventory without opening browser -- CLI is faster for bulk operations -- Import/export works reliably - -### Phase 6: -- Voice recognition accuracy > 95% -- Hands-free operation is practical -- Workshop noise doesn't break it - -### Phase 7: -- Recommendations are useful -- Usage analytics provide insights -- Auto-categorization is accurate - -### Phase 8: -- 99.9% uptime -- Fast response times (< 200ms) -- Mobile UI is smooth -- Backup/restore is reliable - ---- - -## Technology Evolution - -### Current (Phase 1): -``` -Python + Flask -PostgreSQL -Docker -Simple templates -``` - -### Mid-term (Phase 4): -``` -+ sentence-transformers -+ pgvector -+ Better UI framework -``` - -### Long-term (Phase 8): -``` -+ React/Vue for frontend? -+ Redis for caching -+ Celery for background jobs -+ Prometheus for monitoring -+ Mobile app? -``` - ---- - -## Resource Requirements - -### Phase 1 (Current): -- 2GB RAM -- 10GB disk -- Any CPU - -### Phase 4 (Semantic Search): -- 4GB RAM (for embeddings) -- 20GB disk -- CPU: Any (GPU optional) - -### Phase 6 (Voice): -- 4GB RAM -- Dedicated microphone -- Raspberry Pi 4+ recommended - -### Phase 8 (Production): -- 8GB RAM -- 50GB disk (for backups) -- SSD recommended -- Reverse proxy (nginx/Caddy) - ---- - -## Timeline Summary - -| Phase | Weeks | Effort | Dependencies | -|-------|-------|--------|--------------| -| 1 | 1-2 | ✅ Done | None | -| 2 | 3 | Medium | Phase 1 | -| 3 | 4 | Medium | Phase 1 | -| 4 | 5-6 | High | Phase 1 | -| 5 | 7 | Medium | Phase 1, 4 | -| 6 | 8-9 | High | Phase 1, 4, 5 | -| 7 | 10-11 | High | Phase 1, 4 | -| 8 | 12+ | Medium | All previous | - -**Total:** ~3 months for full system - ---- - -## What's Next? - -**Immediate:** -1. Deploy Phase 1 -2. Use it for real inventory -3. Provide feedback -4. Identify pain points - -**Soon:** -1. Decide: Which phase is most important to you? - - Need better organization? → Phase 2 - - Have duplicates? → Phase 3 - - Want AI search? → Phase 4 - - Prefer CLI? → Phase 5 - - Need hands-free? → Phase 6 - -**Future:** -- Phases don't have to be done in order -- Can skip phases not needed -- Can combine phases -- Customize to your needs - ---- - -## Questions to Consider - -1. **How many items will you track?** - - < 1000: Current system is fine - - 1000-10000: Need Phase 4 (search) - - 10000+: Need Phase 7 (optimization) - -2. **How will you use it?** - - At desk: Web UI is fine - - In workshop: Voice (Phase 6) helps - - Quickly: CLI (Phase 5) is fastest - -3. **What's your priority?** - - Better organization: Phase 2 - - Better search: Phase 4 - - Automation: Phase 7 - - Mobile: Phase 8 - ---- - -## Contributing Ideas - -As you use the system, you'll discover: -- Missing features -- Better workflows -- UI improvements -- New use cases - -Document these! They'll inform future phases. - ---- - -## Version History - -- **v1.0.0 (Phase 1)** - Foundation ✅ -- **v2.0.0 (Phase 2)** - Smart locations 🔜 -- **v3.0.0 (Phase 3)** - Duplicate detection 📋 -- **v4.0.0 (Phase 4)** - AI search 📋 -- **v5.0.0 (Phase 5)** - CLI 📋 -- **v6.0.0 (Phase 6)** - Voice 📋 -- **v7.0.0 (Phase 7)** - Advanced AI 📋 -- **v8.0.0 (Phase 8)** - Production 📋 - ---- - -**Ready to build?** Start with Phase 1 (already done!), then pick your next adventure. 🚀 diff --git a/inventory-system/docs/START_HERE.md b/inventory-system/docs/START_HERE.md deleted file mode 100644 index c0e409b..0000000 --- a/inventory-system/docs/START_HERE.md +++ /dev/null @@ -1,456 +0,0 @@ -# 🏠 Homelab Inventory System - START HERE - -## 👋 Welcome! - -You've just downloaded a **complete, working inventory management system** for homelabs, makerspaces, and workshops. - -This package includes: -- ✅ **Fully functional web application** (Phase 1 complete) -- ✅ **Docker deployment** (runs anywhere) -- ✅ **Complete documentation** (everything you need) -- ✅ **Sample data** (see it in action) -- ✅ **8-phase roadmap** (future features planned) - ---- - -## 🚀 Get Started in 3 Steps - -### Step 1: Read This (2 minutes) -You're doing it! 👍 - -### Step 2: Quick Deploy (5 minutes) -```bash -# Extract the package and navigate to it -cd inventory-system - -# Start the system -docker-compose up -d - -# Wait 30 seconds for startup -sleep 30 - -# (Optional) Load sample data -python3 create_sample_data.py -``` - -### Step 3: Access (Now!) -Open your browser: -``` -http://localhost:8080 -``` - -**That's it!** You now have a working inventory system. 🎉 - ---- - -## 📚 What to Read Next? - -### New to the System? -Read these in order: -1. **[INDEX.md](INDEX.md)** - Navigation guide (5 minutes) -2. **[PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md)** - What is this? (10 minutes) -3. **[QUICKSTART.md](inventory-system/QUICKSTART.md)** - Deployment guide (5 minutes) - -### Ready to Deploy? -1. **[DEPLOY.md](DEPLOY.md)** - Comprehensive deployment guide -2. **[TESTING_CHECKLIST.md](TESTING_CHECKLIST.md)** - Verify everything works - -### Daily Operations? -1. **[QUICK_REFERENCE.md](QUICK_REFERENCE.md)** - Commands and workflows - -### Curious About Future? -1. **[ROADMAP.md](ROADMAP.md)** - 8-phase development plan - -### Complete Documentation? -1. **[README.md](inventory-system/README.md)** - Full reference manual - ---- - -## 📦 Package Contents - -``` -homelab-inventory-system/ -├── START_HERE.md ⭐ You are here -├── INDEX.md 📚 Documentation guide -├── PROJECT_OVERVIEW.md 📖 System overview -├── DEPLOY.md 🚀 Deployment guide -├── ROADMAP.md 🗺️ Development plan -├── QUICK_REFERENCE.md 📋 Daily cheat sheet -├── TESTING_CHECKLIST.md ✅ Verification guide -└── inventory-system/ 💻 The application - ├── README.md Complete docs - ├── QUICKSTART.md 5-minute start - ├── docker-compose.yml Container config - ├── backend/ Flask app - ├── frontend/ Web UI - └── create_sample_data.py Sample data -``` - ---- - -## 🎯 What Can It Do? - -### Phase 1 (Available Now) ✅ -- Track unlimited inventory items -- Organize in storage hierarchy (Modules → Levels → Locations) -- Search by keyword -- Web-based UI -- REST API -- Multiple items per location -- Quantity tracking -- Categories and tags -- Location grid visualization - -### Coming Soon 🔜 -- **Phase 2**: Smart location suggestions -- **Phase 3**: Duplicate detection -- **Phase 4**: AI semantic search (natural language) -- **Phase 5**: CLI interface -- **Phase 6**: Voice control -- **Phase 7**: Advanced AI features -- **Phase 8**: Production polish & mobile - ---- - -## ⚡ Quick Reference - -### Essential Commands -```bash -# Start system -docker-compose up -d - -# Stop system -docker-compose stop - -# View logs -docker-compose logs -f - -# Backup database -docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql - -# Access web UI -http://localhost:8080 -``` - -### First-Time Setup -1. Create a module (storage unit) -2. Add levels (drawers/shelves) -3. Add items with descriptions -4. Search to find them! - ---- - -## 🆘 Need Help? - -### Documentation -- **Lost?** Read [INDEX.md](INDEX.md) -- **Quick start?** Read [QUICKSTART.md](inventory-system/QUICKSTART.md) -- **Troubleshooting?** Check [QUICK_REFERENCE.md](QUICK_REFERENCE.md) -- **Complete info?** Read [README.md](inventory-system/README.md) - -### Common Issues -**Can't access UI:** Check `docker-compose logs nginx` -**Items not saving:** Check `docker-compose logs backend` -**Port in use:** Change port in `docker-compose.yml` -**Database error:** Run `docker-compose restart postgres` - -See [QUICK_REFERENCE.md](QUICK_REFERENCE.md) for complete troubleshooting. - ---- - -## ✅ Verify Installation - -After deploying, verify it works: - -```bash -# Check containers are running -docker-compose ps -# Should show 3 containers: postgres, backend, nginx - -# Check web UI -curl -I http://localhost:8080 -# Should return: HTTP/1.1 200 OK - -# Load sample data (optional) -python3 create_sample_data.py -# Creates test modules and items -``` - -See [TESTING_CHECKLIST.md](TESTING_CHECKLIST.md) for comprehensive verification. - ---- - -## 🎓 Learning Path - -### Day 1: Get Started -- [ ] Read this file (START_HERE.md) -- [ ] Deploy system -- [ ] Load sample data -- [ ] Explore web UI -- [ ] Read [QUICK_REFERENCE.md](QUICK_REFERENCE.md) - -### Week 1: Basic Use -- [ ] Create your storage modules -- [ ] Add 20-50 items -- [ ] Test search -- [ ] Read [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) -- [ ] Set up backups - -### Week 2: Advanced -- [ ] Add more inventory -- [ ] Optimize organization -- [ ] Read [README.md](inventory-system/README.md) -- [ ] Run [TESTING_CHECKLIST.md](TESTING_CHECKLIST.md) - -### Week 3+: Mastery -- [ ] Read [ROADMAP.md](ROADMAP.md) -- [ ] Plan Phase 2 needs -- [ ] Customize system -- [ ] Provide feedback - ---- - -## 🔐 Security Note - -**Default settings are for development/home use.** - -For production/internet-facing: -- ⚠️ Change database password -- ⚠️ Set secure SECRET_KEY -- ⚠️ Enable HTTPS -- ⚠️ Configure firewall -- ⚠️ Set up regular backups - -See [DEPLOY.md](DEPLOY.md) security section for details. - ---- - -## 💡 Pro Tips - -1. **Use descriptive item descriptions** - - Good: "Hex head bolt, M6 diameter, 50mm long, zinc plated" - - Bad: "Bolt" - -2. **Tag everything** - - Tags: `bolt, m6, metric, hex, zinc, fastener` - - Makes searching much easier - -3. **Name modules memorably** - - Good: Zeus, Muse, Apollo (or Workshop-Main) - - Bad: Cabinet1, Storage2 - -4. **Backup regularly** - - `docker-compose exec postgres pg_dump -U inventoryuser inventory > backup.sql` - - Set up automated backups (see DEPLOY.md) - -5. **Start small** - - Add 20 items first - - Refine your organization - - Then add more - ---- - -## 📊 System Requirements - -### Minimum -- Docker & Docker Compose -- 2GB RAM -- 10GB disk space -- Any CPU - -### Recommended -- 4GB RAM -- 20GB SSD -- Modern CPU -- Network access for other devices - -### Works On -- Linux (any distro) -- macOS -- Windows (with Docker Desktop) -- Raspberry Pi 4+ -- Proxmox LXC -- VPS/Cloud servers -- Jetson Nano - ---- - -## 🎯 Use Cases - -Perfect for tracking: -- Electronic components (resistors, ICs, modules) -- Fasteners (screws, bolts, nuts, washers) -- Tools (hand tools, power tools, measuring) -- Materials (paints, solvents, adhesives) -- Hardware (standoffs, brackets, connectors) -- Supplies (wire, cable, consumables) - -Ideal environments: -- Homelabs -- Makerspaces -- Home workshops -- Garages -- Electronics labs -- Shared tool libraries - ---- - -## 🏁 Ready to Start? - -### Absolute Minimum to Read: -1. This file (you're reading it!) ✓ -2. [QUICKSTART.md](inventory-system/QUICKSTART.md) (5 min) - -### Recommended: -3. [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) (10 min) -4. [QUICK_REFERENCE.md](QUICK_REFERENCE.md) (5 min) - -### Optional but Useful: -5. [README.md](inventory-system/README.md) (complete docs) -6. [ROADMAP.md](ROADMAP.md) (future plans) - ---- - -## 🚀 Deploy Now! - -```bash -cd inventory-system -docker-compose up -d -``` - -Then open: **http://localhost:8080** - ---- - -## 📞 Support - -### Documentation -All docs included: -- [INDEX.md](INDEX.md) - Navigation -- [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) - Overview -- [QUICKSTART.md](inventory-system/QUICKSTART.md) - Quick start -- [DEPLOY.md](DEPLOY.md) - Deployment -- [QUICK_REFERENCE.md](QUICK_REFERENCE.md) - Daily ops -- [TESTING_CHECKLIST.md](TESTING_CHECKLIST.md) - Testing -- [ROADMAP.md](ROADMAP.md) - Future features -- [README.md](inventory-system/README.md) - Complete reference - -### Troubleshooting -1. Check logs: `docker-compose logs` -2. Review [QUICK_REFERENCE.md](QUICK_REFERENCE.md) -3. Try sample data: `python3 create_sample_data.py` -4. Reset system: `docker-compose down -v && docker-compose up -d` - ---- - -## 📝 Version Info - -- **Version**: 1.0.0 -- **Phase**: 1 (Foundation) - Complete ✅ -- **Date**: October 2024 -- **Status**: Production Ready - ---- - -## 🎉 What's Next? - -After deploying: -1. ✅ Load sample data to explore -2. ✅ Create your first module -3. ✅ Add 10-20 real items -4. ✅ Read [QUICK_REFERENCE.md](QUICK_REFERENCE.md) -5. ✅ Set up daily backups -6. ✅ Enjoy organized storage! - -Future phases will add: -- AI-powered semantic search (Phase 4) -- Voice control (Phase 6) -- CLI interface (Phase 5) -- Advanced features (Phases 7-8) - -See [ROADMAP.md](ROADMAP.md) for details. - ---- - -## ❓ Quick FAQ - -**Q: How much does it cost?** -A: Free! Self-hosted, no subscriptions. - -**Q: How many items can it handle?** -A: 10,000+ easily. PostgreSQL can handle millions. - -**Q: Do I need internet?** -A: No! Completely offline after installation. - -**Q: Can I customize it?** -A: Yes! It's all open source. - -**Q: Is it secure?** -A: Yes for local use. See DEPLOY.md for production hardening. - -**Q: Can multiple people use it?** -A: Yes, but no user accounts yet (Phase 8). - -**Q: What about mobile?** -A: Web UI works on mobile. Native app planned for Phase 8. - -**Q: Can I backup my data?** -A: Yes! Simple PostgreSQL backup. See QUICK_REFERENCE.md. - ---- - -## 🌟 Why This System? - -### vs Spreadsheets -- ✅ Better search -- ✅ Relationship tracking -- ✅ Location visualization -- ✅ Future AI capabilities - -### vs Commercial Software -- ✅ Self-hosted (your data) -- ✅ No subscription fees -- ✅ Unlimited items -- ✅ Fully customizable - -### vs Basic Database -- ✅ User-friendly UI -- ✅ Built for storage -- ✅ Easy deployment -- ✅ Natural language ready - ---- - -## 🎊 Final Words - -This is **Phase 1** of an 8-phase plan. Even at Phase 1, it's a **fully functional, production-ready** inventory system. - -**Start simple. Grow as needed.** - -### Next Steps: -1. Deploy it (5 minutes) -2. Use it (ongoing) -3. Enjoy organized storage! 🎉 - ---- - -## 🗺️ Where to Go From Here? - -**First time?** → [QUICKSTART.md](inventory-system/QUICKSTART.md) - -**Need overview?** → [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) - -**Daily use?** → [QUICK_REFERENCE.md](QUICK_REFERENCE.md) - -**Lost?** → [INDEX.md](INDEX.md) - -**All docs?** → [README.md](inventory-system/README.md) - ---- - -**Ready to organize your homelab? Let's go! 🚀** - ---- - -*Homelab Inventory System v1.0.0 - Phase 1 Complete - October 2024* diff --git a/inventory-system/docs/TESTING_CHECKLIST.md b/inventory-system/docs/TESTING_CHECKLIST.md deleted file mode 100644 index 8456267..0000000 --- a/inventory-system/docs/TESTING_CHECKLIST.md +++ /dev/null @@ -1,763 +0,0 @@ -# ✅ Phase 1 Testing Checklist - -Use this checklist to verify your inventory system is working correctly after deployment. - ---- - -## Pre-Deployment Checks - -### System Requirements -- [ ] Docker installed and running -- [ ] Docker Compose installed -- [ ] Minimum 2GB RAM available -- [ ] Minimum 10GB disk space free -- [ ] Ports 5000, 5432, 8080 available - -**Verification:** -```bash -docker --version -docker-compose --version -df -h -netstat -tuln | grep -E '5000|5432|8080' -``` - ---- - -## Deployment Checks - -### Container Startup -- [ ] All containers start without errors -- [ ] PostgreSQL container is healthy -- [ ] Backend container is running -- [ ] Nginx container is running - -**Verification:** -```bash -cd inventory-system -docker-compose up -d -sleep 30 -docker-compose ps - -# Expected output: 3 containers with "Up" status -# inventory-db Up (healthy) -# inventory-backend Up -# inventory-nginx Up -``` - -### Log Check -- [ ] No errors in PostgreSQL logs -- [ ] Backend initializes database -- [ ] No critical errors in backend logs -- [ ] Nginx starts successfully - -**Verification:** -```bash -docker-compose logs postgres | grep -i error -docker-compose logs backend | grep -i error -docker-compose logs nginx | grep -i error -``` - -### Web Access -- [ ] Can access http://localhost:8080 -- [ ] Homepage loads correctly -- [ ] No 404 or 500 errors -- [ ] Navigation menu appears - -**Verification:** -```bash -curl -I http://localhost:8080 -# Should return: HTTP/1.1 200 OK -``` - ---- - -## Database Checks - -### Connection -- [ ] Backend can connect to PostgreSQL -- [ ] Database 'inventory' exists -- [ ] All tables are created - -**Verification:** -```bash -docker-compose exec postgres psql -U inventoryuser -d inventory -c "\dt" - -# Expected tables: -# - modules -# - levels -# - locations -# - items -# - item_locations -``` - -### Schema -- [ ] `modules` table has correct columns -- [ ] `levels` table has correct columns -- [ ] `locations` table has correct columns -- [ ] `items` table has correct columns -- [ ] `item_locations` table has correct columns -- [ ] All foreign keys are set up -- [ ] All unique constraints exist - -**Verification:** -```bash -docker-compose exec postgres psql -U inventoryuser -d inventory -c "\d modules" -docker-compose exec postgres psql -U inventoryuser -d inventory -c "\d items" -``` - ---- - -## Module Management Tests - -### Create Module -- [ ] Can access "Modules" page -- [ ] "Add Module" button works -- [ ] Can create module with name "TestModule" -- [ ] Description field works -- [ ] Location description field works -- [ ] Module appears in list after creation -- [ ] Can view module details - -**Test Steps:** -1. Navigate to http://localhost:8080/modules -2. Click "Add Module" -3. Fill in: - - Name: TestModule - - Description: Test module for verification - - Location: Test bench -4. Click "Create Module" -5. Verify module appears in list -6. Click module name to view details - -### Edit Module -- [ ] Can click "Edit" on module -- [ ] Can change name -- [ ] Can change description -- [ ] Changes save correctly -- [ ] Updated module displays new info - -### Delete Module -- [ ] Can delete empty module -- [ ] Confirmation prompt appears -- [ ] Module is removed from list - -⚠️ **Note:** Don't delete TestModule yet - needed for level tests - ---- - -## Level Management Tests - -### Create Level -- [ ] Can view module details page -- [ ] "Add Level" button works -- [ ] Can create level with number 1 -- [ ] Row count field works (try 4) -- [ ] Column count field works (try 6) -- [ ] Level appears under module -- [ ] Locations are auto-created (24 for 4×6) - -**Test Steps:** -1. View TestModule details -2. Click "Add Level" -3. Fill in: - - Level Number: 1 - - Name: Test Level - - Rows: 4 - - Columns: 6 - - Description: 4×6 grid test -4. Click "Create Level" -5. Verify level appears -6. Click level to view locations - -### Verify Location Grid -- [ ] Grid displays correctly (4 rows × 6 columns) -- [ ] All locations are present (A1-D6) -- [ ] Can click individual locations -- [ ] Empty locations are indicated -- [ ] Grid is visually clear - -### Edit Level -- [ ] Can edit level details -- [ ] Can change name -- [ ] Can change description -- [ ] Changes save - -⚠️ **Note:** Changing rows/columns after creation doesn't auto-create new locations in Phase 1 - ---- - -## Location Tests - -### View Location -- [ ] Can click on location (e.g., A1) -- [ ] Location details page loads -- [ ] Full address displays (TestModule:1:A1) -- [ ] Empty location shows "No items" - -### Edit Location -- [ ] Can edit location -- [ ] Can set location type (try "medium_bin") -- [ ] Can set dimensions (width, height, depth) -- [ ] Can add notes -- [ ] Changes save correctly - -### Location Types -- [ ] Can select "small_box" -- [ ] Can select "medium_bin" -- [ ] Can select "large_bin" -- [ ] Can select "liquid_container" -- [ ] Can select "smd_container" -- [ ] Can select "general" - ---- - -## Item Management Tests - -### Create Item (Simple) -- [ ] Can access "Items" page -- [ ] "Add Item" button works -- [ ] Can create item with minimal info -- [ ] Name field required -- [ ] Description field required - -**Test Steps:** -1. Navigate to http://localhost:8080/items -2. Click "Add Item" -3. Fill in: - - Name: Test Item - - Description: Simple test item -4. Click "Create Item" -5. Verify item appears in list - -### Create Item (Full) -- [ ] Can fill all fields -- [ ] Category dropdown works -- [ ] Item type dropdown works -- [ ] Quantity field works -- [ ] Unit field works -- [ ] Tags field works (comma-separated) -- [ ] Location can be selected -- [ ] Item saves with all data - -**Test Steps:** -1. Create item with all fields: - - Name: M6x50 Test Bolt - - Description: Hex head bolt, M6 diameter, 50mm long, zinc plated - - Category: Fasteners - - Item Type: solid - - Quantity: 100 - - Unit: pieces - - Tags: bolt, m6, metric, test - - Location: TestModule:1:A1 -2. Verify all data displays correctly - -### View Item -- [ ] Can click item name -- [ ] Item details page loads -- [ ] All fields display correctly -- [ ] Location(s) shown -- [ ] Tags display as list - -### Edit Item -- [ ] Can edit item -- [ ] Can change name -- [ ] Can change description -- [ ] Can change quantity -- [ ] Can add/remove tags -- [ ] Changes save - -### Delete Item -- [ ] Can delete item -- [ ] Confirmation prompt appears -- [ ] Item removed from list - ---- - -## Item-Location Relationship Tests - -### Multiple Locations -- [ ] Can add item to multiple locations -- [ ] Each location shows separately -- [ ] Quantities per location tracked -- [ ] Total quantity calculated correctly - -**Test Steps:** -1. Edit existing item -2. Add to location TestModule:1:B2 -3. Set quantity at B2 to 50 -4. Verify item shows in both A1 and B2 -5. Verify total quantity updates - -### Location Displays Item -- [ ] Navigate to location A1 -- [ ] Item appears in location's item list -- [ ] Item quantity shown -- [ ] Can click item to view details - ---- - -## Search Tests - -### Basic Search -- [ ] Can access search page -- [ ] Search box works -- [ ] Enter "test" finds test items -- [ ] Results display correctly -- [ ] Item details visible in results - -### Search by Name -- [ ] Search for exact item name -- [ ] Item is found -- [ ] Partial name search works - -### Search by Description -- [ ] Search for word in description -- [ ] Items with matching descriptions found -- [ ] Multiple matches displayed - -### Search by Tags -- [ ] Search for tag (e.g., "bolt") -- [ ] Items with that tag found -- [ ] Multiple tags work - -### Search by Category -- [ ] Search for category name -- [ ] Items in that category found - -### No Results -- [ ] Search for non-existent term -- [ ] "No results" message displays -- [ ] No errors occur - ---- - -## API Tests - -### Modules API -```bash -# List modules -curl http://localhost:8080/modules/api/modules - -# Expected: JSON array of modules -``` - -- [ ] Returns valid JSON -- [ ] Contains created modules -- [ ] Status code 200 - -### Items API -```bash -# List items -curl http://localhost:8080/items/api/items - -# Expected: JSON array of items -``` - -- [ ] Returns valid JSON -- [ ] Contains created items -- [ ] Status code 200 - -### Search API -```bash -# Search items -curl "http://localhost:8080/items/api/items?search=test" - -# Expected: JSON array of matching items -``` - -- [ ] Returns filtered results -- [ ] Search query works -- [ ] Status code 200 - -### Locations API -```bash -# List locations -curl http://localhost:8080/locations/api/locations - -# Expected: JSON array of locations -``` - -- [ ] Returns valid JSON -- [ ] Contains created locations -- [ ] Status code 200 - ---- - -## Sample Data Tests - -### Load Sample Data -```bash -python3 create_sample_data.py -``` - -- [ ] Script runs without errors -- [ ] 3 modules created (Zeus, Muse, Apollo) -- [ ] Levels created for each module -- [ ] 10+ items created -- [ ] Items have proper descriptions -- [ ] Can browse sample data in UI - -### Verify Sample Data -- [ ] Zeus module exists -- [ ] Zeus has 3 levels -- [ ] Muse module exists -- [ ] Apollo module exists -- [ ] Electronics items exist -- [ ] Fastener items exist -- [ ] Tool items exist -- [ ] Paint items exist - ---- - -## UI/UX Tests - -### Navigation -- [ ] Home link works -- [ ] Modules link works -- [ ] Items link works -- [ ] Search link works -- [ ] All pages load without errors - -### Responsive Design -- [ ] Desktop view looks good (1920×1080) -- [ ] Laptop view looks good (1366×768) -- [ ] Tablet view works (768×1024) -- [ ] Mobile view works (375×667) - -**Note:** Optimal mobile support comes in Phase 8 - -### Forms -- [ ] All forms have proper labels -- [ ] Required fields marked -- [ ] Validation works -- [ ] Error messages are clear -- [ ] Success messages appear - -### Visual Elements -- [ ] Grid layouts display correctly -- [ ] Tables are readable -- [ ] Buttons are clickable -- [ ] Links are styled -- [ ] Colors are consistent - ---- - -## Performance Tests - -### Load Time -- [ ] Homepage loads in < 2 seconds -- [ ] Module list loads in < 2 seconds -- [ ] Item list loads in < 2 seconds -- [ ] Search results appear quickly - -### With Data -Add 100 items, then: -- [ ] Search still fast (< 1 second) -- [ ] List pages load quickly -- [ ] No noticeable slowdown - -### Concurrent Access -Open 3 browser tabs: -- [ ] All tabs work independently -- [ ] No conflicts -- [ ] Data stays consistent - ---- - -## Data Integrity Tests - -### Relationships -- [ ] Deleting level doesn't orphan locations -- [ ] Deleting module deletes levels -- [ ] Item-location relationships persist -- [ ] No broken foreign keys - -**Test:** -1. Create module with level and items -2. Delete module -3. Verify levels and locations also deleted -4. Items remain but locations removed - -### Unique Constraints -- [ ] Can't create duplicate module names -- [ ] Can't create duplicate level numbers in same module -- [ ] Can't create duplicate locations in same level - -**Test:** -1. Try to create module with existing name -2. Should show error -3. Verify constraint enforced - ---- - -## Backup/Restore Tests - -### Backup -```bash -docker-compose exec postgres pg_dump -U inventoryuser inventory > test_backup.sql -``` - -- [ ] Backup file created -- [ ] File size > 0 bytes -- [ ] Contains SQL statements -- [ ] No errors during backup - -### Restore -```bash -# Reset database -docker-compose down -v -docker-compose up -d -sleep 30 - -# Restore -docker-compose exec -T postgres psql -U inventoryuser inventory < test_backup.sql -``` - -- [ ] Restore completes without errors -- [ ] All modules restored -- [ ] All items restored -- [ ] All relationships intact -- [ ] Can access UI with restored data - ---- - -## Error Handling Tests - -### Invalid Input -- [ ] Empty required fields show error -- [ ] Invalid numbers rejected -- [ ] SQL injection attempts blocked -- [ ] XSS attempts sanitized - -**Test:** -1. Try to create item without name -2. Try to create item without description -3. Try negative quantity -4. Try SQL in name field: `'; DROP TABLE items; --` -5. Verify all rejected gracefully - -### 404 Handling -- [ ] Invalid URLs show 404 page -- [ ] Non-existent item IDs handled -- [ ] Non-existent module IDs handled - -**Test:** -```bash -curl http://localhost:8080/items/999999 -curl http://localhost:8080/invalid-page -``` - -### Network Issues -- [ ] Graceful handling if database unreachable -- [ ] Error message shown to user -- [ ] System recovers after database restart - -**Test:** -```bash -docker-compose stop postgres -# Try to use UI - should show error -docker-compose start postgres -# Wait 10 seconds, try again - should work -``` - ---- - -## Security Tests (Phase 1 Basic) - -### Port Exposure -```bash -netstat -tuln | grep -E '5432|5000|8080' -``` - -- [ ] Port 8080 open (nginx) -- [ ] Port 5432 open locally (if needed for admin) -- [ ] Port 5000 only accessible via nginx -- [ ] No unnecessary ports open - -### SQL Injection -- [ ] Try SQL injection in search -- [ ] Try SQL injection in item name -- [ ] Verify SQLAlchemy prevents injection -- [ ] No direct SQL exposed - -**Test:** -```bash -curl "http://localhost:8080/items/api/items?search=' OR '1'='1" -# Should return empty or safe results, not all items -``` - -⚠️ **Note:** Full security hardening comes in Phase 8 - ---- - -## Clean-up Tests - -### Reset System -```bash -docker-compose down -v -docker-compose up -d -``` - -- [ ] All data deleted -- [ ] Fresh database created -- [ ] System starts clean -- [ ] No orphaned data - -### Storage Space -```bash -du -sh data/ -``` - -- [ ] Database size reasonable (< 100MB for test data) -- [ ] No excessive log files -- [ ] Backups not accumulating - ---- - -## Documentation Tests - -### README -- [ ] All commands in README work -- [ ] Examples are accurate -- [ ] Links are not broken -- [ ] Screenshots match UI (if included) - -### QUICKSTART -- [ ] 5-minute deployment actually works -- [ ] Sample data script works -- [ ] First-time setup instructions accurate - -### API Documentation -- [ ] All listed endpoints work -- [ ] Examples are correct -- [ ] Response formats match documentation - ---- - -## Success Criteria - -Phase 1 is considered fully working if: - -- ✅ All containers run reliably -- ✅ Can create modules, levels, locations -- ✅ Can add, edit, delete items -- ✅ Search finds items correctly -- ✅ Multiple items per location works -- ✅ UI is usable and clear -- ✅ No data loss or corruption -- ✅ API endpoints respond correctly -- ✅ Backup/restore works -- ✅ Sample data loads successfully - ---- - -## Checklist Summary - -### Critical (Must Pass) -- [ ] System starts (docker-compose up -d) -- [ ] Web UI accessible -- [ ] Can create modules -- [ ] Can create items -- [ ] Search works -- [ ] No data loss -- [ ] Backup works - -### Important (Should Pass) -- [ ] All CRUD operations work -- [ ] Grid visualization works -- [ ] API endpoints respond -- [ ] Sample data loads -- [ ] Error handling works - -### Nice to Have (Good if Pass) -- [ ] Performance is good -- [ ] UI is polished -- [ ] Mobile view works -- [ ] Documentation complete - ---- - -## Test Report Template - -``` -# Phase 1 Test Report - -Date: ___________ -Tester: ___________ - -## Environment -- OS: ___________ -- Docker Version: ___________ -- RAM: ___________ -- Disk Space: ___________ - -## Results -- Critical Tests Passed: ___ / ___ -- Important Tests Passed: ___ / ___ -- Nice-to-Have Tests Passed: ___ / ___ - -## Issues Found -1. ___________ -2. ___________ -3. ___________ - -## Overall Status -[ ] PASS - Ready for use -[ ] PARTIAL - Usable with limitations -[ ] FAIL - Not ready - -## Notes -___________________________________________ -___________________________________________ -``` - ---- - -## Next Steps After Testing - -### If All Tests Pass: -1. ✅ Start using the system for real inventory -2. ✅ Add your first 20-50 items -3. ✅ Set up regular backups -4. ✅ Provide feedback for Phase 2 - -### If Some Tests Fail: -1. Document failures -2. Check logs: `docker-compose logs` -3. Try rebuilding: `docker-compose up --build` -4. Reset if needed: `docker-compose down -v && docker-compose up -d` -5. Retry failed tests - -### If Critical Tests Fail: -1. Check prerequisites (Docker, ports, etc.) -2. Review error messages -3. Check system resources -4. Consult troubleshooting in README -5. Open issue with logs - ---- - -## Continuous Testing - -As you use the system: - -### Daily -- [ ] Backup works -- [ ] Items save correctly -- [ ] Search finds items - -### Weekly -- [ ] No performance degradation -- [ ] No data corruption -- [ ] All features still working - -### Monthly -- [ ] Full backup/restore test -- [ ] Review any errors in logs -- [ ] Check disk space usage - ---- - -**Happy Testing! 🧪✅** - -Once you've verified Phase 1 works correctly, you're ready to start using it for real inventory management! diff --git a/inventory-system/docs/VERSION.md b/inventory-system/docs/VERSION.md deleted file mode 100644 index b9c11bd..0000000 --- a/inventory-system/docs/VERSION.md +++ /dev/null @@ -1,249 +0,0 @@ -# Homelab Inventory System - Version Information - -## Current Version -**Version**: 1.0.0 -**Phase**: 1 - Foundation -**Release Date**: 2024 -**Status**: Production Ready - -## Phase 1: Foundation (Current) - -### Features Completed ✅ -- Complete storage hierarchy (Modules → Levels → Locations) -- Full CRUD operations for all entities -- Web UI with responsive design -- Basic keyword search -- Visual location grids -- RESTful API endpoints -- PostgreSQL database backend -- Docker deployment with Docker Compose -- Comprehensive documentation (4 guides) -- Sample data generator - -### Technology Stack -- **Backend**: Python 3.11+, Flask 3.0, SQLAlchemy 2.0 -- **Database**: PostgreSQL 15 -- **Frontend**: HTML5, CSS3, JavaScript ES6+ -- **Infrastructure**: Docker, Docker Compose, nginx -- **Deployment**: VPS, Proxmox, Jetson Nano compatible - -### Known Limitations -- No AI-powered semantic search -- No duplicate detection -- No location suggestions -- No CLI interface -- No voice interface -- Single-user system (no authentication) -- Basic keyword search only - -## Upcoming Phases - -### Phase 2: Smart Location Management (Planned) -**Target**: Week 3 -**Features**: -- Location type constraints -- Smart location suggestions -- Size compatibility checking -- Visual location picker -- Proximity-based suggestions - -### Phase 3: Duplicate Detection (Planned) -**Target**: Week 4 -**Features**: -- Fuzzy string matching -- Pattern recognition for common formats -- Similar item warnings -- Merge suggestions -- Attribute extraction - -### Phase 4: Semantic Search (Planned) -**Target**: Week 5-6 -**Features**: -- Sentence transformer embeddings (BERT/SBERT) -- Natural language queries -- Semantic similarity matching -- Ranked search results -- pgvector integration - -### Phase 5: CLI Interface (Planned) -**Target**: Week 7 -**Features**: -- Command-line tool (invctl) -- Interactive REPL mode -- Batch operations -- CSV import/export -- Tab completion - -### Phase 6: Voice Interface (Planned) -**Target**: Week 8-9 -**Features**: -- Wake word detection (Porcupine) -- Speech-to-text (Whisper/Vosk) -- Text-to-speech response -- Natural language understanding -- Hands-free operation - -### Phase 7: Advanced AI Features (Planned) -**Target**: Week 10-11 -**Features**: -- Fine-tuned semantic models -- Usage analytics -- Smart categorization -- Reorganization suggestions -- Alternative part recommendations - -### Phase 8: Production Polish (Planned) -**Target**: Week 12+ -**Features**: -- User authentication -- Multi-user support -- Mobile optimization -- QR code generation -- Barcode scanning -- Advanced monitoring -- Automated backups - -## Release History - -### v1.0.0 - Phase 1 Foundation (2024) -**Initial Release** -- Complete Phase 1 feature set -- Production-ready deployment -- Comprehensive documentation -- Docker-based deployment -- RESTful API -- Web interface -- Sample data generator - -## Compatibility - -### Minimum Requirements -- **OS**: Linux (Ubuntu 20.04+, Debian 11+, any Docker-capable OS) -- **RAM**: 2GB minimum, 4GB recommended -- **Disk**: 10GB minimum, 20GB recommended -- **Docker**: 20.10+ -- **Docker Compose**: 2.0+ -- **Python**: 3.11+ (for development/sample data) - -### Tested Platforms -- ✅ Ubuntu 22.04 LTS -- ✅ Ubuntu 24.04 LTS -- ✅ Debian 12 -- ✅ Docker Desktop (macOS/Windows) -- ✅ Proxmox LXC containers -- ✅ VPS (DigitalOcean, Linode, AWS EC2) -- ⚠️ Jetson Nano (ARM64 - minor adjustments may be needed) - -### Browser Compatibility -- ✅ Chrome 90+ -- ✅ Firefox 88+ -- ✅ Safari 14+ -- ✅ Edge 90+ -- ✅ Mobile browsers (iOS Safari, Chrome Mobile) - -## Database Schema Version -**Schema Version**: 1.0 -**Tables**: 5 (modules, levels, locations, items, item_locations) -**Migration Support**: Flask-Migrate ready (to be implemented) - -## API Version -**API Version**: 1.0 -**Endpoint Prefix**: `/api/` (for JSON endpoints) -**Authentication**: None (Phase 1) -**Rate Limiting**: None (Phase 1) - -## Dependencies - -### Python Packages -``` -Flask==3.0.0 -Flask-SQLAlchemy==3.1.1 -Flask-Migrate==4.0.5 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 -sqlalchemy==2.0.23 -``` - -### Docker Images -``` -postgres:15-alpine -python:3.11-slim -nginx:alpine -``` - -## Security - -### Current Security Features -- SQL injection protection (SQLAlchemy ORM) -- CSRF protection (Flask) -- Input validation -- Prepared statements - -### Security Limitations (Phase 1) -- No user authentication -- No authorization/roles -- No TLS/HTTPS (development mode) -- Default database passwords -- No rate limiting -- No audit logging - -### Production Security Checklist -See README.md for complete production deployment guide. - -## Support - -### Documentation -- README.md - Complete user and developer guide -- QUICKSTART.md - 5-minute deployment guide -- ARCHITECTURE.md - Technical deep dive -- DEPLOYMENT_SUMMARY.md - Overview and next steps -- PROJECT_SUMMARY.md - Complete project information - -### Getting Help -1. Check documentation -2. Review troubleshooting sections -3. Check Docker logs -4. Open GitHub issue (when available) - -## License -[Your License Here] - -## Credits -Designed and built for homelab and makerspace inventory management. - -## Roadmap Timeline - -``` -Phase 1 (Current) ████████████ Complete -Phase 2 (Week 3) ░░░░░░░░░░░░ Planned -Phase 3 (Week 4) ░░░░░░░░░░░░ Planned -Phase 4 (Week 5-6) ░░░░░░░░░░░░ Planned -Phase 5 (Week 7) ░░░░░░░░░░░░ Planned -Phase 6 (Week 8-9) ░░░░░░░░░░░░ Planned -Phase 7 (Week 10-11) ░░░░░░░░░░░░ Planned -Phase 8 (Week 12+) ░░░░░░░░░░░░ Planned -``` - -## Migration Notes - -### From Phase 1 to Phase 2 -- No database schema changes -- New service layer for location suggestions -- Backward compatible - -### From Phase 2 to Phase 3 -- No database schema changes -- New duplicate detection service -- Backward compatible - -### From Phase 3 to Phase 4 -- Database extension: pgvector -- New embeddings table -- Migration script provided -- Backward compatible - ---- - -**Last Updated**: 2024 -**Maintained By**: [Your Name/Team] -**Project Status**: Active Development diff --git a/inventory-system/frontend/static/css/style.css b/inventory-system/frontend/static/css/style.css deleted file mode 100644 index 812cab5..0000000 --- a/inventory-system/frontend/static/css/style.css +++ /dev/null @@ -1,829 +0,0 @@ -/* Reset and Base Styles */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --primary-color: #2563eb; - --primary-hover: #1d4ed8; - --secondary-color: #64748b; - --success-color: #10b981; - --danger-color: #ef4444; - --warning-color: #f59e0b; - --bg-color: #f8fafc; - --card-bg: #ffffff; - --border-color: #e2e8f0; - --text-primary: #1e293b; - --text-secondary: #64748b; - --text-muted: #94a3b8; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background-color: var(--bg-color); - color: var(--text-primary); - line-height: 1.6; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 0 20px; -} - -/* Navigation */ -.navbar { - background-color: var(--card-bg); - border-bottom: 1px solid var(--border-color); - padding: 1rem 0; - margin-bottom: 2rem; -} - -.navbar .container { - display: flex; - justify-content: space-between; - align-items: center; -} - -.nav-brand a { - font-size: 1.5rem; - font-weight: bold; - color: var(--primary-color); - text-decoration: none; -} - -.nav-menu { - display: flex; - list-style: none; - gap: 2rem; -} - -.nav-menu a { - color: var(--text-secondary); - text-decoration: none; - transition: color 0.2s; -} - -.nav-menu a:hover { - color: var(--primary-color); -} - -/* Flash Messages */ -.flash-messages { - margin-bottom: 2rem; -} - -.alert { - padding: 1rem; - border-radius: 0.5rem; - margin-bottom: 1rem; - position: relative; -} - -.alert-success { - background-color: #d1fae5; - color: #065f46; - border: 1px solid #6ee7b7; -} - -.alert-error { - background-color: #fee2e2; - color: #991b1b; - border: 1px solid #fca5a5; -} - -.alert-warning { - background-color: #fef3c7; - color: #92400e; - border: 1px solid #fcd34d; -} - -.alert-close { - position: absolute; - right: 1rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: inherit; - opacity: 0.5; -} - -.alert-close:hover { - opacity: 1; -} - -/* Page Header */ -.page-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2rem; -} - -.page-header h1 { - font-size: 2rem; - color: var(--text-primary); -} - -.header-actions { - display: flex; - gap: 1rem; -} - -.breadcrumb { - font-size: 0.875rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; -} - -.breadcrumb a { - color: var(--primary-color); - text-decoration: none; -} - -.breadcrumb a:hover { - text-decoration: underline; -} - -/* Buttons */ -.btn { - display: inline-block; - padding: 0.5rem 1rem; - border-radius: 0.375rem; - text-decoration: none; - border: none; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: all 0.2s; -} - -.btn-primary { - background-color: var(--primary-color); - color: white; -} - -.btn-primary:hover { - background-color: var(--primary-hover); -} - -.btn-secondary { - background-color: var(--secondary-color); - color: white; -} - -.btn-secondary:hover { - background-color: #475569; -} - -.btn-danger { - background-color: var(--danger-color); - color: white; -} - -.btn-danger:hover { - background-color: #dc2626; -} - -.btn-sm { - padding: 0.25rem 0.75rem; - font-size: 0.75rem; -} - -.btn-link { - background: none; - color: var(--primary-color); - padding: 0.25rem 0.5rem; - font-size: 0.875rem; -} - -.btn-link:hover { - text-decoration: underline; -} - -.text-danger { - color: var(--danger-color); -} - -/* Dashboard */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1.5rem; - margin-bottom: 3rem; -} - -.stat-card { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; -} - -.stat-value { - font-size: 2.5rem; - font-weight: bold; - color: var(--primary-color); - margin-bottom: 0.5rem; -} - -.stat-label { - color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 0.5rem; -} - -.stat-link { - color: var(--primary-color); - text-decoration: none; - font-size: 0.875rem; -} - -.stat-link:hover { - text-decoration: underline; -} - -/* Quick Actions */ -.quick-actions { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 2rem; -} - -.quick-actions h2 { - margin-bottom: 1rem; - font-size: 1.25rem; -} - -.action-buttons { - display: flex; - gap: 1rem; - flex-wrap: wrap; -} - -/* Modules */ -.modules-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.module-card { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - transition: box-shadow 0.2s; -} - -.module-card:hover { - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.module-card h3 { - margin-bottom: 0.5rem; -} - -.module-card h3 a { - color: var(--text-primary); - text-decoration: none; -} - -.module-card h3 a:hover { - color: var(--primary-color); -} - -.module-description { - color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 1rem; -} - -.module-stats { - display: flex; - gap: 1rem; - font-size: 0.875rem; - color: var(--text-muted); -} - -/* Module List */ -.modules-list { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.module-item { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; -} - -.module-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.module-header h2 { - font-size: 1.5rem; - margin: 0; -} - -.module-header h2 a { - color: var(--text-primary); - text-decoration: none; -} - -.module-header h2 a:hover { - color: var(--primary-color); -} - -.module-actions { - display: flex; - gap: 0.5rem; -} - -.module-location { - color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 0.5rem; -} - -/* Levels */ -.levels-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.level-card { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; -} - -.level-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.level-header h3 { - font-size: 1.25rem; - margin: 0; -} - -.level-header h3 a { - color: var(--text-primary); - text-decoration: none; -} - -.level-header h3 a:hover { - color: var(--primary-color); -} - -.level-actions { - display: flex; - gap: 0.5rem; -} - -.level-info { - display: flex; - gap: 1rem; - font-size: 0.875rem; - color: var(--text-muted); - margin-top: 0.5rem; -} - -/* Location Grid */ -.location-grid { - overflow-x: auto; - margin-bottom: 1rem; -} - -.grid-table { - width: 100%; - border-collapse: collapse; - background-color: var(--card-bg); -} - -.grid-table th, -.grid-table td { - border: 1px solid var(--border-color); - padding: 0.5rem; - text-align: center; -} - -.grid-table th { - background-color: var(--bg-color); - font-weight: 600; -} - -.grid-cell { - min-width: 80px; - min-height: 60px; - cursor: pointer; - transition: background-color 0.2s; -} - -.grid-cell.occupied { - background-color: #dbeafe; -} - -.grid-cell.empty { - background-color: #f1f5f9; -} - -.grid-cell:hover { - background-color: #bfdbfe; -} - -.location-link { - display: block; - text-decoration: none; - color: var(--text-primary); - padding: 0.5rem; -} - -.location-address { - font-weight: 600; - font-size: 0.875rem; -} - -.location-items { - font-size: 0.75rem; - color: var(--primary-color); - margin-top: 0.25rem; -} - -.location-empty { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 0.25rem; -} - -.location-missing { - color: var(--text-muted); -} - -.grid-legend { - display: flex; - gap: 2rem; - margin-top: 1rem; - font-size: 0.875rem; -} - -.legend-item { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.legend-color { - width: 20px; - height: 20px; - border: 1px solid var(--border-color); - border-radius: 0.25rem; -} - -.legend-color.occupied { - background-color: #dbeafe; -} - -.legend-color.empty { - background-color: #f1f5f9; -} - -/* Tables */ -.data-table { - width: 100%; - border-collapse: collapse; - background-color: var(--card-bg); - border-radius: 0.5rem; - overflow: hidden; -} - -.data-table th, -.data-table td { - padding: 0.75rem 1rem; - text-align: left; - border-bottom: 1px solid var(--border-color); -} - -.data-table th { - background-color: var(--bg-color); - font-weight: 600; - color: var(--text-secondary); - font-size: 0.875rem; - text-transform: uppercase; -} - -.data-table tbody tr:hover { - background-color: var(--bg-color); -} - -.text-muted { - color: var(--text-muted); -} - -/* Badges */ -.badge { - display: inline-block; - padding: 0.25rem 0.75rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; - background-color: var(--bg-color); - color: var(--text-secondary); - margin-right: 0.25rem; -} - -.badge-occupied { - background-color: #dbeafe; - color: #1e40af; -} - -.badge-empty { - background-color: #f1f5f9; - color: var(--text-muted); -} - -/* Forms */ -.form { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 2rem; - max-width: 800px; -} - -.form-group { - margin-bottom: 1.5rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: var(--text-primary); -} - -.form-group input[type="text"], -.form-group input[type="number"], -.form-group input[type="email"], -.form-group select, -.form-group textarea { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 1rem; - font-family: inherit; -} - -.form-group input:focus, -.form-group select:focus, -.form-group textarea:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); -} - -.form-group small { - display: block; - margin-top: 0.25rem; - color: var(--text-muted); - font-size: 0.875rem; -} - -.form-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; -} - -.form-actions { - display: flex; - gap: 1rem; - margin-top: 2rem; -} - -.form-inline { - display: flex; - gap: 1rem; - align-items: center; - flex-wrap: wrap; -} - -.form-inline input, -.form-inline select { - flex: 1; - min-width: 150px; -} - -/* Filters */ -.filters { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 2rem; -} - -.filter-form { - display: flex; - gap: 1rem; - align-items: center; - flex-wrap: wrap; -} - -.filter-form input, -.filter-form select { - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 0.875rem; -} - -.filter-form input { - flex: 1; - min-width: 200px; -} - -/* Detail Views */ -.detail-section { - margin-bottom: 1.5rem; -} - -.detail-section strong { - display: block; - margin-bottom: 0.5rem; - color: var(--text-secondary); - font-size: 0.875rem; - text-transform: uppercase; -} - -.detail-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1.5rem; - margin-bottom: 1.5rem; -} - -.detail-item strong { - display: block; - margin-bottom: 0.25rem; - color: var(--text-secondary); - font-size: 0.875rem; -} - -.item-details, -.module-details, -.location-details { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 2rem; -} - -.tags { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.tag { - display: inline-block; - padding: 0.25rem 0.75rem; - border-radius: 9999px; - background-color: var(--primary-color); - color: white; - font-size: 0.75rem; -} - -/* Search */ -.search-form { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 2rem; - margin-bottom: 2rem; -} - -.search-form form { - display: flex; - gap: 1rem; -} - -.search-form input { - flex: 1; - padding: 0.75rem 1rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 1rem; -} - -.result-count { - color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 1rem; -} - -/* Danger Zone */ -.danger-zone { - background-color: #fef2f2; - border: 1px solid #fecaca; - border-radius: 0.5rem; - padding: 1.5rem; - margin-top: 2rem; -} - -.danger-zone h3 { - color: var(--danger-color); - margin-bottom: 0.5rem; -} - -.danger-zone p { - color: var(--text-secondary); - margin-bottom: 1rem; -} - -/* Empty State */ -.empty-state { - text-align: center; - padding: 3rem; - color: var(--text-muted); -} - -.empty-state p { - margin-bottom: 1rem; -} - -/* Stats Inline */ -.stats-inline { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -/* Footer */ -.footer { - margin-top: 4rem; - padding: 2rem 0; - border-top: 1px solid var(--border-color); - text-align: center; - color: var(--text-muted); - font-size: 0.875rem; -} - -/* Responsive */ -@media (max-width: 768px) { - .navbar .container { - flex-direction: column; - gap: 1rem; - } - - .nav-menu { - flex-direction: column; - gap: 0.5rem; - text-align: center; - } - - .page-header { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } - - .stats-grid { - grid-template-columns: 1fr; - } - - .form-row { - grid-template-columns: 1fr; - } - - .filter-form { - flex-direction: column; - align-items: stretch; - } - - .filter-form input, - .filter-form select { - width: 100%; - } -} diff --git a/inventory-system/frontend/static/js/main.js b/inventory-system/frontend/static/js/main.js deleted file mode 100644 index d502d48..0000000 --- a/inventory-system/frontend/static/js/main.js +++ /dev/null @@ -1,77 +0,0 @@ -// Main JavaScript for Inventory System - -// Auto-hide flash messages after 5 seconds -document.addEventListener('DOMContentLoaded', function() { - const alerts = document.querySelectorAll('.alert'); - alerts.forEach(alert => { - setTimeout(() => { - alert.style.opacity = '0'; - alert.style.transition = 'opacity 0.5s'; - setTimeout(() => alert.remove(), 500); - }, 5000); - }); -}); - -// Confirm delete actions -function confirmDelete(message) { - return confirm(message || 'Are you sure you want to delete this item?'); -} - -// Dynamic location selector -// This can be expanded in future phases for smarter location suggestions -function initLocationSelector() { - const moduleSelect = document.getElementById('module_select'); - const levelSelect = document.getElementById('level_select'); - const locationSelect = document.getElementById('location_select'); - - if (!moduleSelect || !levelSelect || !locationSelect) return; - - moduleSelect.addEventListener('change', async function() { - const moduleId = this.value; - if (!moduleId) { - levelSelect.innerHTML = ''; - locationSelect.innerHTML = ''; - return; - } - - // Fetch levels for selected module - const response = await fetch(`/modules/api/modules/${moduleId}/levels`); - const levels = await response.json(); - - levelSelect.innerHTML = ''; - levels.forEach(level => { - const option = document.createElement('option'); - option.value = level.id; - option.textContent = `Level ${level.level_number}${level.name ? ' - ' + level.name : ''}`; - levelSelect.appendChild(option); - }); - - locationSelect.innerHTML = ''; - }); - - levelSelect.addEventListener('change', async function() { - const levelId = this.value; - if (!levelId) { - locationSelect.innerHTML = ''; - return; - } - - // Fetch locations for selected level - const response = await fetch(`/locations/api/locations?level_id=${levelId}`); - const locations = await response.json(); - - locationSelect.innerHTML = ''; - locations.forEach(location => { - const option = document.createElement('option'); - option.value = location.id; - const occupied = location.item_count > 0 ? ' (Occupied)' : ''; - option.textContent = `${location.full_address}${occupied}`; - locationSelect.appendChild(option); - }); - }); -} - -// Initialize on page load -document.addEventListener('DOMContentLoaded', function() { - initLocationSelector(); -}); diff --git a/inventory-system/frontend/templates/base.html b/inventory-system/frontend/templates/base.html deleted file mode 100644 index 31d96cf..0000000 --- a/inventory-system/frontend/templates/base.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - {% block title %}Homelab Inventory System{% endblock %} - - {% block extra_css %}{% endblock %} - - - - -
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
- {{ message }} - -
- {% endfor %} -
- {% endif %} - {% endwith %} - - {% block content %}{% endblock %} -
- -
-
-

© 2024 Homelab Inventory System | Phase 1: Foundation

-
-
- - - {% block extra_js %}{% endblock %} - - diff --git a/inventory-system/frontend/templates/index.html b/inventory-system/frontend/templates/index.html deleted file mode 100644 index 0bfeecf..0000000 --- a/inventory-system/frontend/templates/index.html +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Dashboard - Homelab Inventory{% endblock %} - -{% block content %} -
-

📦 Inventory Dashboard

- -
-
-
{{ stats.modules }}
-
Modules
- View all → -
- -
-
{{ stats.levels }}
-
Levels
-
- -
-
{{ stats.locations }}
-
Locations
- View all → -
- -
-
{{ stats['items'] }}
-
Items
- View all → -
-
- -
-

Quick Actions

- -
- - {% if modules %} -
-

Storage Modules

-
- {% for module in modules %} -
-

- - {{ module.name }} - -

-

{{ module.description or 'No description' }}

-
- {{ module.levels|length }} levels - {% set total_locations = module.levels|map(attribute='locations')|map('length')|sum %} - {{ total_locations }} locations -
-
- {% endfor %} -
-
- {% endif %} - - {% if recent_items %} -
-

Recently Added Items

- - - - - - - - - - - - {% for item in recent_items %} - - - - - - - - {% endfor %} - -
NameDescriptionCategoryLocationsActions
{{ item.name }}{{ item.description[:80] }}{% if item.description|length > 80 %}...{% endif %}{{ item.category or '-' }} - {% if item.item_locations %} - {{ item.item_locations|length }} location(s) - {% else %} - No location - {% endif %} - - View -
-
- {% endif %} -
-{% endblock %} diff --git a/inventory-system/frontend/templates/items/form.html b/inventory-system/frontend/templates/items/form.html deleted file mode 100644 index 2824053..0000000 --- a/inventory-system/frontend/templates/items/form.html +++ /dev/null @@ -1,93 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{% if item %}Edit{% else %}New{% endif %} Item - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - -
- -
- - - Natural language description of the item -
- -
-
- - - - -
- -
- - - - -
-
- -
- - - Comma-separated tags -
- -
- - -
- - {% if not item %} -

Initial Location

-
- - -
- {% endif %} - -
- - Cancel -
-
- -{% if item %} -
-

Danger Zone

-
- -
-
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/items/list.html b/inventory-system/frontend/templates/items/list.html deleted file mode 100644 index 1e6ca7d..0000000 --- a/inventory-system/frontend/templates/items/list.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Items - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - - - Clear -
-
- -{% if items %} - - - - - - - - - - - - {% for item in items %} - - - - - - - - {% endfor %} - -
NameDescriptionCategoryLocationsActions
{{ item.name }}{{ item.description[:100] }}{% if item.description|length > 100 %}...{% endif %}{{ item.category or '-' }} - {% if item.item_locations %} - {% for il in item.item_locations %} - {{ il.location.full_address() }} - {% endfor %} - {% else %} - No location - {% endif %} - - View - Edit -
-{% else %} -
-

No items found.

- + Add Item -
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/items/view.html b/inventory-system/frontend/templates/items/view.html deleted file mode 100644 index cb1bc2f..0000000 --- a/inventory-system/frontend/templates/items/view.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ item.name }} - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- Description: -

{{ item.description }}

-
- -
- {% if item.category %} -
- Category: - {{ item.category }} -
- {% endif %} - - {% if item.item_type %} -
- Type: - {{ item.item_type }} -
- {% endif %} -
- - {% if item.tags %} -
- Tags: -
- {% for tag in item.tags.split(',') %} - {{ tag.strip() }} - {% endfor %} -
-
- {% endif %} - - {% if item.notes %} -
- Notes: -

{{ item.notes }}

-
- {% endif %} -
- -

Storage Locations

- -{% if item.item_locations %} - - - - - - - - - - {% for il in item.item_locations %} - - - - - - {% endfor %} - -
LocationNotesActions
- - {{ il.location.full_address() }} - - {{ il.notes or '-' }} -
- -
-
-{% else %} -
-

This item has no storage locations assigned.

-
-{% endif %} - -
-

Add Storage Location

-
- - - -
-
- -{% endblock %} diff --git a/inventory-system/frontend/templates/levels/form.html b/inventory-system/frontend/templates/levels/form.html deleted file mode 100644 index 7cde937..0000000 --- a/inventory-system/frontend/templates/levels/form.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{% if level %}Edit{% else %}New{% endif %} Level - {{ module.name }} - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - - Numeric level identifier (1, 2, 3, etc.) -
- -
- - - Optional custom name for this level -
- -
- - - Number of rows (A, B, C... up to Z) -
- -
- - - Number of columns (1, 2, 3...) -
- -
- - - Optional description -
- - {% if level %} -
- Note: Changing the grid size will delete all existing locations and recreate them. -
- {% endif %} - -
- - Cancel -
-
- -{% if level %} -
-

Danger Zone

-

Deleting this level will also delete all its locations. This action cannot be undone.

-
- -
-
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/levels/view.html b/inventory-system/frontend/templates/levels/view.html deleted file mode 100644 index 8ecef95..0000000 --- a/inventory-system/frontend/templates/levels/view.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ level.module.name }} - Level {{ level.level_number }} - Homelab Inventory{% endblock %} - -{% block content %} - - -{% if level.description %} -
-

{{ level.description }}

-
-{% endif %} - -
- Grid: {{ level.rows }} × {{ level.columns }} - {{ level.locations|length }} locations -
- -

Location Grid

- -
- - - - - {% for col in range(1, level.columns + 1) %} - - {% endfor %} - - - - {% for row_idx in range(level.rows) %} - {% set row = [chr(65 + row_idx)] if level.rows <= 26 else [row_idx + 1|string] %} - - - {% for col in range(1, level.columns + 1) %} - {% set col_str = col|string %} - {% set location = location_grid.get(row[0], {}).get(col_str) %} - - {% endfor %} - - {% endfor %} - -
{{ col }}
{{ row[0] }} - {% if location %} - -
{{ row[0] }}{{ col }}
- {% if location.item_locations %} -
{{ location.item_locations|length }} item(s)
- {% else %} -
Empty
- {% endif %} -
- {% else %} -
-
- {% endif %} -
-
- -
-
- Occupied -
-
- Empty -
-
- -{% endblock %} diff --git a/inventory-system/frontend/templates/locations/form.html b/inventory-system/frontend/templates/locations/form.html deleted file mode 100644 index 342539b..0000000 --- a/inventory-system/frontend/templates/locations/form.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Edit Location - {{ location.full_address() }} - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - - - - Type of storage location -
- -

Dimensions (optional)

-
-
- - -
- -
- - -
- -
- - -
-
- -
- - -
- -
- - Cancel -
-
- -{% endblock %} diff --git a/inventory-system/frontend/templates/locations/list.html b/inventory-system/frontend/templates/locations/list.html deleted file mode 100644 index 9e3db61..0000000 --- a/inventory-system/frontend/templates/locations/list.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Locations - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - - {% if location_types %} - - {% endif %} - - - Clear -
-
- -{% if locations %} - - - - - - - - - - - - - {% for location in locations %} - - - - - - - - - {% endfor %} - -
LocationModuleLevelTypeItemsActions
{{ location.full_address() }}{{ location.level.module.name if location.level and location.level.module else '-' }}Level {{ location.level.level_number if location.level else '-' }}{{ location.location_type }} - {% if location.item_locations %} - {{ location.item_locations|length }} item(s) - {% else %} - Empty - {% endif %} - - View - Edit -
-{% else %} -
-

No locations found.

-
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/locations/view.html b/inventory-system/frontend/templates/locations/view.html deleted file mode 100644 index 4dfcbc5..0000000 --- a/inventory-system/frontend/templates/locations/view.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ location.full_address() }} - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
-
- Type: - {{ location.location_type }} -
- - {% if location.width_mm or location.height_mm or location.depth_mm %} -
- Dimensions: - - {% if location.width_mm %}W: {{ location.width_mm }}mm{% endif %} - {% if location.height_mm %} H: {{ location.height_mm }}mm{% endif %} - {% if location.depth_mm %} D: {{ location.depth_mm }}mm{% endif %} - -
- {% endif %} - -
- Status: - - {% if item_locations %}Occupied ({{ item_locations|length }} items){% else %}Empty{% endif %} - -
-
- - {% if location.notes %} -
- Notes: -

{{ location.notes }}

-
- {% endif %} -
- -

Items Stored Here

- -{% if item_locations %} - - - - - - - - - - - {% for il in item_locations %} - - - - - - - {% endfor %} - -
ItemDescriptionNotesActions
- - {{ il.item.name }} - - {{ il.item.description[:80] }}{% if il.item.description|length > 80 %}...{% endif %}{{ il.notes or '-' }} - View Item -
-{% else %} -
-

This location is empty.

-
-{% endif %} - -{% endblock %} diff --git a/inventory-system/frontend/templates/modules/form.html b/inventory-system/frontend/templates/modules/form.html deleted file mode 100644 index a807621..0000000 --- a/inventory-system/frontend/templates/modules/form.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{% if module %}Edit{% else %}New{% endif %} Module - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - - Unique name for this storage module (e.g., "Zeus", "Main Workbench", "Muse") -
- -
- - - Optional description of what's stored here -
- -
- - - Where this module is located in your lab/shop (e.g., "North wall", "Under bench") -
- -
- - Cancel -
-
- -{% if module %} -
-

Danger Zone

-

Deleting this module will also delete all its levels and locations. This action cannot be undone.

-
- -
-
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/modules/list.html b/inventory-system/frontend/templates/modules/list.html deleted file mode 100644 index 8613c41..0000000 --- a/inventory-system/frontend/templates/modules/list.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Modules - Homelab Inventory{% endblock %} - -{% block content %} - - -{% if modules %} -
- {% for module in modules %} -
- - - {% if module.description %} -

{{ module.description }}

- {% endif %} - - {% if module.location_description %} -

📍 {{ module.location_description }}

- {% endif %} - -
- {{ module.levels|length }} levels - {% set total_locations = module.levels|map(attribute='locations')|map('length')|sum %} - {{ total_locations }} locations -
-
- {% endfor %} -
-{% else %} -
-

No modules yet. Create your first storage module to get started!

- + Add Module -
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/modules/view.html b/inventory-system/frontend/templates/modules/view.html deleted file mode 100644 index 18f1a18..0000000 --- a/inventory-system/frontend/templates/modules/view.html +++ /dev/null @@ -1,74 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ module.name }} - Homelab Inventory{% endblock %} - -{% block content %} - - -
- {% if module.description %} -
- Description: -

{{ module.description }}

-
- {% endif %} - - {% if module.location_description %} -
- Physical Location: -

📍 {{ module.location_description }}

-
- {% endif %} - -
- Statistics: -
- {{ levels|length }} levels - {% set total_locations = levels|map(attribute='locations')|map('length')|sum %} - {{ total_locations }} locations -
-
-
- -

Levels

- -{% if levels %} -
- {% for level in levels %} -
- - - {% if level.description %} -

{{ level.description }}

- {% endif %} - -
- Grid: {{ level.rows }} × {{ level.columns }} - {{ level.locations|length }} locations -
-
- {% endfor %} -
-{% else %} -
-

No levels yet. Add levels to organize storage locations.

- + Add Level -
-{% endif %} -{% endblock %} diff --git a/inventory-system/frontend/templates/search/results.html b/inventory-system/frontend/templates/search/results.html deleted file mode 100644 index 9816212..0000000 --- a/inventory-system/frontend/templates/search/results.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Search - Homelab Inventory{% endblock %} - -{% block content %} - - -
-
- - -
-
- -{% if query %} -
-

Results for "{{ query }}"

- - {% if results %} -

Found {{ results|length }} item(s)

- - - - - - - - - - - - - {% for item in results %} - - - - - - - - {% endfor %} - -
NameDescriptionCategoryLocationsActions
{{ item.name }}{{ item.description[:100] }}{% if item.description|length > 100 %}...{% endif %}{{ item.category or '-' }} - {% if item.item_locations %} - {% for il in item.item_locations %} - {{ il.location.full_address() }} - {% endfor %} - {% else %} - No location - {% endif %} - - View -
- {% else %} -
-

No items found matching "{{ query }}"

-
- {% endif %} -
-{% endif %} - -{% endblock %} diff --git a/inventory-system/nginx.conf b/inventory-system/nginx.conf deleted file mode 100644 index bdb2a52..0000000 --- a/inventory-system/nginx.conf +++ /dev/null @@ -1,26 +0,0 @@ -events { - worker_connections 1024; -} - -http { - upstream backend { - server backend:5000; - } - - server { - listen 80; - server_name localhost; - - location / { - proxy_pass http://backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /static { - proxy_pass http://backend/static; - } - } -} diff --git a/prototypes/grid-v1.html b/prototypes/grid-v1.html new file mode 100644 index 0000000..5927539 --- /dev/null +++ b/prototypes/grid-v1.html @@ -0,0 +1,1264 @@ + + + + + +WhereTF — Grid Prototype v1 + + + + + + + + +
+ + +
+ Modules +
+ + + + +
+
+ + +
+ + +
+
Levels
+
+
+ + +
+
+

MUSE / Level 3

+
+ + + +
+
+ + + + +
+ +
+ +
+
Empty
+
Occupied
+
Provisional
+
Disabled
+
Co-stored
+
Search result
+
+
+ + +
+
+

MUSE / Level 3

+
+
Status
Select a cell to view details
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+ + + + diff --git a/prototypes/grid-v1a.html b/prototypes/grid-v1a.html new file mode 100644 index 0000000..55c1c59 --- /dev/null +++ b/prototypes/grid-v1a.html @@ -0,0 +1,833 @@ + + + + + +WhereTF — Grid Prototype v1 + + + + + + + + +
+

Modules

+ +
+
MUSE
+
11 levels · Red cabinet
+
+
+ +
+
ALEX
+
9 drawers · IKEA unit
+
+
+ +
+
NEON
+
10 drawers · Parts cabinet
+
+
+ +
+
AKRO
+
16 drawers · Small parts
+
+
+ +
+
MUSE — Levels
+
+ Level 1 + plano-3600 +
+
+ Level 2 + plano-3600 +
+
+ Level 3 + plano-3600 +
+
+ Level 4 + plano-3600 +
+
+ Level 5 + plano-3600 +
+
+ Level 6 + empty +
+
+ Level 7 + plano-3600 +
+
+ Level 8 + 2 unplaced +
+
+
+ + +
+
+

MUSE / Level 3

+
+ + + +
+
+ + + + +
+ +
+ +
+
Empty
+
Occupied
+
Provisional
+
Disabled
+
Co-stored
+
Search result
+
+
+ + +
+ +

+
+
+ + +
+
+
+
+
+
+
+ + + + + diff --git a/prototypes/grid-v1b.html b/prototypes/grid-v1b.html new file mode 100644 index 0000000..e8f5bb2 --- /dev/null +++ b/prototypes/grid-v1b.html @@ -0,0 +1,1082 @@ + + + + + +WhereTF — Grid Prototype v1 + + + + + + + + +
+ + +
+ Modules +
+ + + + +
+
+ + +
+ + +
+
Levels
+
+
+ + +
+
+

MUSE / Level 3

+
+ + + +
+
+ + + + +
+ +
+ +
+
Empty
+
Occupied
+
Provisional
+
Disabled
+
Co-stored
+
Search result
+
+
+ + +
+ +

+
+
+ +
+
+ + +
+
+
+
+
+
+
+ + + + diff --git a/prototypes/item-mgmt-v1.html b/prototypes/item-mgmt-v1.html new file mode 100644 index 0000000..a147a4f --- /dev/null +++ b/prototypes/item-mgmt-v1.html @@ -0,0 +1,1645 @@ + + + + + +WhereTF — Item Management Prototype v1 + + + + + + + +
+
+
+ 🔍 + + +
+
+
+
+
Categories
+
+
+
+ +
+
+ Items + +
+
+ + + +
+
+ +
+ +
+
Select an item to view details
+ +
+ + + + diff --git a/prototypes/navigator-v1.html b/prototypes/navigator-v1.html new file mode 100644 index 0000000..27bc34c --- /dev/null +++ b/prototypes/navigator-v1.html @@ -0,0 +1,1793 @@ + + + + + +WhereTF — Navigator Prototype v1 + + + + + + + + +
+ + +
+ Modules +
+ + + + +
+
+ + +
+ + +
+ + + + + + + +
+
Levels
+
+
+
+ + +
+
+

MUSE / Level 3

+
+ + + +
+
+ + + + +
+ +
+ +
+
Empty
+
Occupied
+
Provisional
+
Disabled
+
Co-stored
+
Selected
+ + +
+
+ + +
+
+

MUSE / Level 3

+
+
Status
Select a cell to view details
+
+
+ + +
+
+
+
+ Pick List + 0 items +
+
+ +
+
+
+
+ +
+
+ + +
+ + +
+ + +
+
+
+
+
+
+
+ + + + diff --git a/prototypes/search-v1.html b/prototypes/search-v1.html new file mode 100644 index 0000000..32589b8 --- /dev/null +++ b/prototypes/search-v1.html @@ -0,0 +1,942 @@ + + + + + +WhereTF — Search Prototype v1 + + + + + + +
+ + +
+
+
+ Results +
+
+
+ +
+
+

MUSE / Level 3

+
+
+
+
+ +
+
+
Empty
+
Occupied
+
Search hit
+
In pick list
+
+
+ +
+
+

Search

+
+
+
Tip
+
Click a result or grid cell to inspect. Use + to add items to your pick list.
+
+
+
+
+
+
+ Pick List + 0 items +
+
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+
+
+ + + + diff --git a/prototypes/storage-def-v1.html b/prototypes/storage-def-v1.html new file mode 100644 index 0000000..afb4e6d --- /dev/null +++ b/prototypes/storage-def-v1.html @@ -0,0 +1,1205 @@ + + + + + +WhereTF — Storage Definition Prototype + + + + + + + +
+
+ +
+ +
+ + +
+
+

Modules

+ +
+
+
+

MUSE

+

Red metal cabinet, 11 shelf levels, under workbench

+
+ 11 levels + 7 inserts placed +
+
+
+
+

ALEX

+

IKEA ALEX drawer unit, 9 drawers, left of desk

+
+ 9 drawers + 4 inserts placed +
+
+
+
+

AKRO

+

Akro-Mils 10116, 16-drawer small parts cabinet

+
+ 16 drawers + 0 inserts +
+
+
+
+

LOUVER

+

Wall-mounted louvered panel, 8 rows, hanging bins

+
+ 8 rows + 12 inserts +
+
+
+
+
+ + +
+
+

New Module

+

Define a physical storage unit

+
+
+
+
+
+
+
+ + +
Short identifier. This becomes the first segment of every location path.
+
+
+ + +
+ +
+
+ + +
+
+

New Module

+

Define the primary dimension

+
+
+
+
+
+
+
+
+ + + + +
What are the subdivisions called?
+
+
+ + +
+
+
+
1Level 1
+
2Level 2
+
3Level 3
+
4Level 4
+
5Level 5
+
+ +
+
+ + +
+
+

New Module

+

Configure levels

+
+
+
+
+
+
+ + + + + + + + + + + +
LabelTypeNotes
+
+ + +
+ +
+
+ + +
+
+

New Module

+

Review and create

+
+
+
+
+
+
+
+
+
+
BENCH
+
Workbench pegboard and shelf unit, 5 levels
+
+
5 levels
+
+ + + + + + +
Level 1receptacle
Level 2receptacle
Level 3receptacle
Level 4receptacle
Level 5receptacle
+
+ +
+
+ + +
+
+
+
+

MUSE

+

Red metal cabinet, 11 shelf levels, under workbench

+
+ 11 levels + 7 inserts + 62% occupied +
+
+
+ + + + + + + + + + + + + + + + + +
LevelTypeInsertStatusOcc.
1rec.Plano 3600 #1active18/24
2rec.Plano 3600 #2active12/24
3rec.Plano 3600 #3active20/22
4rec.Plano 3600 #4active6/24
5rec.Plano 3600 #5active22/24
6rec.Plano 3600 #6active9/24
7rec.Plano 3600 #7active0/24
8rec.active
9rec.active
10rec.disabled
11rec.active
+
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+

Templates

+ +
+ + + + + + + + + + +
NameTypeVersionDimensionsInstances
Plano 3600 Stowawayfixedv24 × 67
Gridfinity Baseplateparametricv11×1 – 12×124
Akro-Mils 30220fixedv11 × 112
Akro-Mils 30230fixedv11 × 16
+
+ + +
+
+
+

New Template

+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
Which directions can cells be merged? Depends on physical divider construction.
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+

Plano 3600 Stowaway

+ fixed +
+

4-row × 6-column organizer with removable dividers. Standard Plano Stowaway form factor.

+ +

Version History

+ + + + + + +
VersionDimsPublishedInstances
v24 × 6Mar 15, 20265active
v14 × 4Mar 1, 20262
+ + +

Instances

+
+
+ + Plano 3600 #1 + MUSE → Level 1 + v2 +
+
+ + Plano 3600 #2 + MUSE → Level 2 + v2 +
+
+ + Plano 3600 #3 + MUSE → Level 3 + v2 +
+
+ + Plano 3600 #4 + MUSE → Level 4 + v1 — upgrade available +
+
+ + Plano 3600 #6 + MUSE → Level 6 + v1 — upgrade available +
+
+ + Plano 3600 #5 + MUSE → Level 5 + v2 +
+
+
+ +
+
+
+
+
+

v2 Layout

+
+
+
+

Properties

+
+
+
+
Dimensions
+
+ + rows × + + cols +
+
+
+
Row labels
+
+ + +
+
+
+
Column labels
+
+ + +
+ +
+
+
Origin
+ +
+
+
Row dividers
+ +
+
+
Column dividers
+ +
+
+
Unit system
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+

LOUVER

+

Wall-mounted louvered panel, 8 rows, hanging bins

+
+ 8 rows + 12 inserts + Continuous-dimension +
+
+
+ + + + + + + + + + + + +
RowWidthUsedInserts
136 in14.5 in (40%)3
236 in22 in (61%)4
336 in8.75 in (24%)2
436 in10.875 in (30%)1
536 in0 in0
636 in0 in0
736 in0 in0
836 in0 in0
+
+
+
+
+

Row 1 — 36 in capacity

+
+
+
+
30220
+
30220
+
30230
+
+
+
+ 14.5 in used + 21.5 in remaining +
+
+
+ +
+
+
+
+
+
+ +
+
+ + + + + +
+ + + + + diff --git a/prototypes/taxonomy-v1.html b/prototypes/taxonomy-v1.html new file mode 100644 index 0000000..f62da6f --- /dev/null +++ b/prototypes/taxonomy-v1.html @@ -0,0 +1,1301 @@ + + + + + +WhereTF — Taxonomy Admin Prototype v1 + + + + + + + + +
+ + + +
+ + + Aspects (5) + + +
+ + + +
+
+
Machine Screw Threading
+
+ 5 params + 3 standards + 24 items +
+
+
+
Pipe Threading
+
+ 5 params + 2 standards + 8 items +
+
+
+
SMD Package
+
+ 4 params + 2 standards + 31 items +
+
+
+
Fastener Drive
+
+ 3 params + 3 standards + 16 items +
+
+ +
+ +
+ +
+ + +
+ + + Parameters (12) + + +
+ + + +
+
+
pitch
+
+ numeric + mm + 2 aspects +
+
+
+
major_dia
+
+ numeric + mm + 2 aspects +
+
+
+
minor_dia
+
+ numeric + mm + 2 aspects +
+
+
+
thread_angle
+
+ numeric + deg + 2 aspects +
+
+
+
drive_type
+
+ enum + 1 aspect +
+
+
+
drive_system
+
+ enum + 1 aspect +
+
+
+
drive_size
+
+ numeric + mm + 1 aspect +
+
+
+
length
+
+ numeric + mm + 1 aspect +
+
+
+
width
+
+ numeric + mm + 1 aspect +
+
+
+
lead_count
+
+ numeric + 1 aspect +
+
+
+
tapered
+
+ boolean + 1 aspect +
+
+
+
class_of_fit
+
+ enum + 1 aspect +
+
+
+
+ + +
+ + +
+
+

Machine Screw Threading

+
Parameters and standards for machine screw thread specifications
+
+ + +
+
Parameters (5)
+
+
+ pitch + numeric + mm +
+ +
+
+
+ major_dia + numeric + mm +
+ +
+
+
+ minor_dia + numeric + mm +
+ +
+
+
+ thread_angle + numeric + deg +
+ +
+
+
+ class_of_fit + enum +
+ +
+ + +
+ + +
+
+ + +
+
+ Standards (3) + +
+ +
+
+ UNC + Unified Thread Standard + 8 desig. +
+ +
+
+
+ UNF + Unified Thread Standard + 6 desig. +
+ +
+
+
+ ISO 261 + 12 desig. +
+ +
+ + + +
+ + +
+
Usage
+ Applied to 24 items +
+ + +
+ +
+
+ + + + + + + +
+ + + + diff --git a/prototypes/taxonomy-v2.html b/prototypes/taxonomy-v2.html new file mode 100644 index 0000000..d0e2744 --- /dev/null +++ b/prototypes/taxonomy-v2.html @@ -0,0 +1,913 @@ + + + + + +WhereTF — Taxonomy Admin v2 + + + + + + + +
+ +
+

Taxonomy

+
+ + +
+
+ +
+ + +
+ +
+ + + + 5 aspects +
+ +
+ +
+
+
+
Machine Screw Threading
+
+ 5 params + 3 standards + 24 items +
+
+
+
Pipe Threading
+
+ 5 params + 2 standards + 8 items +
+
+
+
SMD Package
+
+ 4 params + 2 standards + 31 items +
+
+
+
Fastener Drive
+
+ 3 params + 3 standards + 16 items +
+
+
+
Wire Gauge
+
+ 3 params + 1 standard + 5 items +
+
+ +
+
+ + + +
+
+ + + +
+ + + + + + + diff --git a/prototypes/taxonomy-v3.html b/prototypes/taxonomy-v3.html new file mode 100644 index 0000000..de2a6d7 --- /dev/null +++ b/prototypes/taxonomy-v3.html @@ -0,0 +1,1548 @@ + + + + + +WhereTF — Taxonomy Admin v3 + + + + + + + + +
+
+
+ 🔍 + +
+
+ + +
+ + 5 +
+
+ +
+ +
+ + +
+ + 16 +
+
+ +
+ +
Show hidden
+
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + +
+ Select an aspect or parameter to view details. +
+
+ + +
+ + +
+
Select an aspect or parameter to view details.
+
+
+ + + + diff --git a/prototypes/zoom-v1.htm b/prototypes/zoom-v1.htm new file mode 100644 index 0000000..62e7019 --- /dev/null +++ b/prototypes/zoom-v1.htm @@ -0,0 +1,614 @@ + + + + + +WhereTF — Zoom Prototype v1 (Semiconductor Layout) + + + + +
+

WhereTF

+ Semiconductor Layout View — All Modules +
+ + +
+ +
+
+ + +
+ 31 results across 4 modules +
+ +
+ MUSE (6 hits)L1L22L3L41L5L61L7L8L91L10L111ALEX (8 hits)L13L2L3L42L5L6L71L81L91NEON (10 hits)L12L2L33L41L5L6L7L8L93L101AKRO (7 hits)L1L2L31L4L5L6L71L8L9L10L112L121L131L14L151L16 +
MUSE:4
+
+ +
+
Empty
+
Occupied
+
Search hit
+
Picked
+
+ +
+
NEON / Level 1
+
7/20 occupied — 2 search hits
+
Grid: 5×4
+
+ + + + + \ No newline at end of file diff --git a/prototypes/zoom-v1.htm:Zone.Identifier b/prototypes/zoom-v1.htm:Zone.Identifier new file mode 100644 index 0000000..053d112 --- /dev/null +++ b/prototypes/zoom-v1.htm:Zone.Identifier @@ -0,0 +1,3 @@ +[ZoneTransfer] +ZoneId=3 +HostUrl=about:internet diff --git a/specification/ai-agent-architecture.md b/specification/ai-agent-architecture.md new file mode 100644 index 0000000..f2e40ef --- /dev/null +++ b/specification/ai-agent-architecture.md @@ -0,0 +1,94 @@ +# AI Agent Architecture (Deferred — Reference Specification) + +This documents the agent architecture developed in the v1 codebase. AI integration is deferred for the initial rebuild, but the patterns here inform future implementation. + +## Router-Specialist Pattern + +A lightweight router agent classifies user intent and delegates to domain specialists: + +- **Router** (gpt-4o-mini) — intent classification only, no direct data access. Forces tool calls for every message (except greetings). Cheap. +- **Storage Specialist** (gpt-4o) — manages templates, modules, inserts, storage structure +- **Inventory Specialist** (gpt-4o) — manages items, assignments, location queries + +The router has only two tools: `runStorageAgent` and `runInventoryAgent`. Each takes a `task` string. For compound requests ("create MUSE with 11 levels and put resistors in level 5"), the router breaks into separate specialist calls with accumulated context. + +## Tool Definition Structure + +Tools are the interface between agents and business logic: + +``` +name: "createItem" +description: "Create a new item in the inventory" +category: "items" +handler: "items.create" ← dispatch key +parameters: [{ name, type, description, required, enum? }] +``` + +Handler string format determines dispatch: +- `agents.runStorage` — delegate to specialist (recursive agent execution) +- `items.create` — direct handler lookup in registry + +Tools are formatted for OpenAI's function-calling API at runtime. + +## Handler Dispatch + +Centralized handler registry maps handler strings to functions: + +``` +handlerMap = { + 'templates.create': templateHandlers.create, + 'modules.list': moduleHandlers.list, + 'items.merge': itemHandlers.merge, + 'assignments.assign': assignmentHandlers.assign, + // ~30+ handlers +} +``` + +All handlers follow a consistent signature: +- Arguments object (tool parameters) + userId +- Return result object on success, `{ error: "msg" }` on failure +- Errors thrown in repositories are caught and returned gracefully — the LLM can reason about failures and retry + +## Agent Execution Loop + +1. Format tools for this agent into OpenAI schema +2. Build message array from system prompt, history, and current message +3. If specialist (not router): set `tool_choice: 'required'` to prevent hallucinated answers +4. Call OpenAI +5. **Tool iteration** (max 10 rounds): + - For each tool call in response: + - If `agents.*` handler → recursively execute specialist agent + - Otherwise → look up and execute handler + - Push results back as tool messages + - Continue until LLM produces a text response (no more tool calls) +6. Post-process: strip filler phrases ("Feel free to ask", "Let me know if...") +7. Return content + agent name + optional tool call audit trail + +## Patterns Worth Preserving + +### Name-to-ID Resolution +AI passes names ("MUSE") not database IDs. Handlers resolve automatically — accept either, look up by name if not a valid ID. + +### Path Normalization +Grid cells referenced in multiple formats ("A1", "A,1", "A 1"). Handler normalizes all to a canonical form. + +### Multi-Level Path Inference +If AI provides a flat path like "MUSE level 3 cell A1", handler auto-splits into module path + insert-internal path. + +### Atomic Multi-Step Operations +Operations like item merge (reassign all assignments from duplicates to keeper, delete duplicates) are atomic — no partial failures. + +### Specialist Tracking +When router → specialist → response, the UI shows which specialist answered, not just "router." + +### Caching +In-memory maps for tools and agents. Tools are global, agents are per-user. No TTL — session-scoped. Cleared between test scenarios via `clearAgentCaches()`. + +## Reimplementation Notes + +The architecture is database-agnostic. When reimplementing on PostgreSQL: +- Agent and tool definitions can be database tables or static configuration (static is simpler for a small agent count) +- Handler registry stays as code — no need to be database-driven +- Keep name-to-ID resolution and path normalization patterns +- Session/history storage moves to PostgreSQL with proper indexing +- Error handling model (throw in repo, catch in handler, return to LLM) carries over unchanged diff --git a/specification/ai-collaboration-poc-to-production.md b/specification/ai-collaboration-poc-to-production.md new file mode 100644 index 0000000..aee5ceb --- /dev/null +++ b/specification/ai-collaboration-poc-to-production.md @@ -0,0 +1,277 @@ +# Working with Claude (or any capable AI) to take a POC to a well-architected system + +A working guide distilled from the WhereTF build. Not theory — written while +the project itself was evolving from a rough idea into a structured codebase +with AI doing most of the typing. + +The short version: the AI is a fast, confident, inexperienced collaborator. +Treat it like a brilliant intern who has never seen this codebase before and +doesn't care about the blast radius of what it's about to type. Your job is +to supply the judgment, the memory, and the accountability. + +--- + +## The phases + +POC → production is not a single refactor. It's a sequence with different +failure modes at each stage. + +1. **Rough scaffold.** Everything works, nothing is trustworthy. +2. **Model settling.** Core nouns and verbs of the domain lock in. Schema + stops churning daily. +3. **Interaction refinement.** UX surfaces drive new edges. Data model + usually absorbs them without structural change — if it can't, you're + back at #2. +4. **Correctness hardening.** Tests, constraints, transaction boundaries, + cascades. The day you discover a bug that caused silent data loss is + the day you realize #3 was premature. +5. **Cleanup + convention.** Duplication extracted, names normalized, types + tightened, migrations idempotent, footguns guarded. + +You don't get to skip phases. AI can accelerate each one by 3-10x, but +applying the wrong phase's habits to another phase is where things go +sideways. A POC with phase-5 discipline dies of ceremony. Production with +phase-1 discipline burns on contact. + +--- + +## The core collaboration loop + +Every meaningful change should pass through: + +### 1. Capture + +Get the problem into writing before any code is generated. A one-sentence +prompt is not a capture — it's a bet that the AI will infer the right +abstraction. It won't. It'll infer *an* abstraction and defend it. + +Minimum viable capture: the problem, why it matters, what has been ruled +out, the constraint you most care about. Ideally in a living doc (this +project uses `specification/*-issues.md`) you can grep, reference, and +extend. + +### 2. Clarify + +Before generating code, the AI should list its clarifying questions and +*wait*. You should refuse to answer questions that feel like they're +trying to short-circuit a design decision. Answers that shape the data +model or the interaction are yours to make. Implementation details +(naming a helper, picking an internal data structure) are fine to delegate. + +Heuristic: if the question starts with "should I" and the answer changes +the *observable* behavior of the system, answer it yourself. If it starts +with "how should I" and only changes internal structure, let the AI choose. + +### 3. Plan + +For anything non-trivial, get a plan back before any code lands. The plan +should be specific enough that you can predict the diff: files touched, +migrations added, API routes changed, tests added. A plan that reads +"I'll add the feature and test it" is not a plan. + +Plans also serve as a commit-message draft. The best commits are plans +whose prose survived verbatim from the pre-code step. + +### 4. Execute + +Small commits, each one a single reversible unit. One schema change + +its migration + its backfill + its repo methods + its tests + its API +route + its UI hook is fine as a single commit *if* you can describe it +in one sentence. Two sentences = two commits. + +The AI will sometimes want to fix adjacent things while it's in there. +Push back. "Just this thing, the other is a separate commit." The +blast radius of a compound commit is where most regression hunts end up. + +### 5. Verify + +Reality check the diff before trusting the green tests. Known traps: + +- Tests that run but assert nothing meaningful. +- Tests that re-assert what the code does rather than what the *spec* + requires. +- "Passed with 0 failures" on an empty suite. +- Integration tests that share state with the dev environment. (We hit + this one — `npm test` was truncating the dev database because + `DATABASE_URL` wasn't overridden. Running the suite wiped the user's + workshop. This is the single best example of a POC habit — "it works + on my machine, ship it" — surviving into production setup.) + +For any PR that touches persistence, the check is: *can I point at the +single commit that introduces this state change, and does that commit +include the migration, the backfill, the repo method, and the test that +fails without the migration*? If any one is missing, you have drift in +flight. + +--- + +## Habits that work + +**Treat the issues doc as source of truth.** When the user says "capture +this, don't fix it yet" — that's an invitation to make the doc the +artifact. Code is how we respond to it. Over weeks, the doc accumulates +the real shape of the product. + +**Answer questions inline in the conversation.** Every clarifying question +gets a one-line answer, then the plan is finalized. This keeps the +context small enough that the AI's working memory isn't overrun. + +**Commit at sensible boundaries.** One commit per logical unit. Don't +batch. A single well-written commit message is the best documentation +you'll ever write, and it only exists when the commit is small enough +to need one sentence. + +**Keep a running migration count.** If the project has `0007_foo.sql`, +the next one is `0008_bar.sql`. Never skip. Never rename. Write the +migration SQL by hand (or generate it and read it line by line). Let the +AI write the Drizzle schema — but the SQL, *you* own. + +**Name the footguns.** When you hit one, write it down as a +caveat in the issue doc (or in the file the footgun lives in), and add +the fence in code. Silent data loss cannot be rediscovered — it has to +be *impossible*. + +**Extract at three, not two.** Two copies of something is duplication +but not a problem yet. Three copies is a pattern. Four is a refactor. +Extracting at two produces the wrong abstraction half the time. + +**Defer what you don't need.** Every sentence of the spec that can wait +should wait. The AI wants to be complete; completeness is the enemy of +shipping. + +--- + +## Habits that go wrong + +**Trusting generated code without reading it.** Even for "simple" things. +The AI will cheerfully write a transaction-log query that scans the +entire table, a React useEffect with a missing dep, a migration that +works on empty tables and silently truncates on populated ones. + +**Letting the AI rewrite instead of edit.** Ask for an edit, get an edit. +Ask for "clean this up" and you'll get a full rewrite that loses context +you didn't know the file carried. + +**Accepting a commit without reading the diff.** This is the single most +reliable way to introduce silent regressions. The AI will write confident +commit messages for commits that change things the message doesn't +mention. + +**Treating the generated tests as ground truth.** The tests will be +against the implementation as written, not the spec. Your job is to +supply the tests — or at least the assertions — that reflect the spec +the code is trying to meet. + +**Letting feature creep redefine the data model.** Every UI sketch looks +like it wants a new column. Most of them are variations on existing +columns. If you find yourself adding the third column to support a fourth +UI variation, stop. The model is wrong. + +**Chasing the AI's confident wrong answer.** When the AI asserts a +framework behaves a certain way and you're 80% sure it doesn't, go +verify. Don't let the chat keep going with the wrong premise. It +*will* build a rococo house on the wrong foundation and defend every +brick. + +--- + +## What the AI actually does well + +- Boilerplate: schema + migration + repo + route + test for a new entity, + in ~5 minutes of conversation. Do this often. +- Mechanical refactors: "rename X to Y across the codebase", "extract + these three copies into a shared helper", "convert this SVG renderer + to CSS Grid". High win rate when the before/after is well-defined. +- Reading unfamiliar code: ask it to survey a module and describe what + it sees, check its reading against reality. Much faster than doing it + by hand, given you read the summary critically. +- Test scaffolding: spinning up the boilerplate for a test case, letting + you fill in the assertions. +- Commit messages: a good prompt + the diff → a respectable message. + +## What it does poorly + +- Anything involving judgment about tradeoffs. It will produce a + recommendation with confident framing whether or not it has the + information to make one. Always ask it to list the tradeoffs before + acting. +- State that's subtle. Event loops, race conditions, transaction + isolation, cascading deletes. The wrong choice looks identical to the + right choice until the 10th user. +- Distinguishing POC shortcuts from load-bearing architecture. It will + happily reach into a core abstraction to patch a UI bug because the + fix "fits better there". +- Long-running context. Give it 500 lines of prior decisions and ask for + the 501st — it may contradict decision 37. Keep the durable decisions + in a file, not only in the chat. + +--- + +## Signal that the project is on the right track + +- **Spec files grow faster than code.** If you're churning specs, you're + preventing churn in code. +- **Commits are small and easy to name.** The commit title = the feature. +- **Tests are numerous and fast.** You run them often. They're honest + (they'd catch the thing the feature prevents). +- **The migrations log is clean.** Sequential, each one explainable, none + destructive. +- **Naming stays stable.** When you see a word in a commit message, you + know what part of the system it refers to. +- **Deletion works correctly.** You can delete any entity and the + transaction log records the cascade. Nothing orphans. + +## Signal the project is drifting + +- **Recurring "oh, right" bugs.** Same kind of mistake in a new place. + Missing constraint, duplicated logic, inconsistent name. → Invest in a + convention + a lint or test that enforces it. +- **Commits that touch six areas.** Either the change is too big or the + areas are wrongly separated. +- **You'd rather rewrite a feature than read it.** The AI made it in the + first place; it shouldn't be unreadable after a week. Read + simplify + before extending. +- **You can't remember why a column exists.** Go annotate it. Migration + comments are cheap. +- **You avoid running tests because they're slow or flaky.** A test + suite you don't trust is a test suite you don't run. + +--- + +## Minimum guardrails for production-readiness + +A POC graduating to production needs all of these in place. None is +optional. AI can write all of them in an afternoon; you need to ask. + +1. **Test DB is not the dev DB.** Verified by a guard that refuses to + run destructive operations on any DB whose name doesn't contain + `test`. (We caught this the hard way.) +2. **Migrations are numbered and append-only.** No rewriting history. +3. **Every destructive action is logged.** Transaction log with + before/after state, type-discriminated. +4. **Every cascade is explicit.** Either an FK `ON DELETE CASCADE` or a + repo method that does the cleanup in a transaction. +5. **Every API error has a status code that lets the UI distinguish + kinds.** 409 for "state conflict", 404 for "not found", etc. Not + everything is 400. +6. **Every state transition has a test that tries to violate the + invariant and expects a throw.** Not just the happy path. +7. **No magic strings for enums.** Either a literal union type or a + database check constraint. +8. **Secrets are not in the repo.** `.env.local.example`, not + `.env.local`. +9. **The README (or equivalent) tells a new developer how to seed, how + to run tests, and what service the dev DB runs on.** +10. **When a feature is deferred, it's noted somewhere retrievable + — not just in commit history.** + +--- + +## One last thing + +The AI will say "shipped" when what it means is "the code compiled and +the tests I wrote passed". Your sign-off is different — "this changes +the behavior of the system in the way I intended, nothing else, and I +understand every line of the diff." Those are not the same event. + +Ship on your own terms. The AI is the keyboard, not the architect. diff --git a/specification/deployment.md b/specification/deployment.md new file mode 100644 index 0000000..00aa1e5 --- /dev/null +++ b/specification/deployment.md @@ -0,0 +1,285 @@ +# Deployment + +WhereTF is FOSS (AGPL-3.0). CI publishes container images to the +public GitHub Container Registry; anyone can pull, run, and +self-host. The project's own homelab deployment pulls from the same +registry. + +The open repo produces Docker artifacts only. Deploy orchestration +(hostnames, secrets, Ansible, Caddy config) lives in a separate +private infrastructure repo and is not part of this codebase. + +--- + +## Artifacts + +| Path | Purpose | +|------|---------| +| `web/Dockerfile` | Multi-stage: `deps`, `builder`, `migrator`, `runner`. | +| `web/.dockerignore` | Keeps `node_modules`, `.next`, secrets, and local Claude state out of the build context. | +| `docker-compose.yml` | Reference compose at repo root: runs app + Postgres + migration job end-to-end on a laptop. | +| `docker-compose.dev.yml` | Dev-only Postgres. Used with `npm run dev` against the host. | +| `web/app/api/health/route.ts` | Liveness probe. Always 200 if Node is servicing HTTP. | +| `web/app/api/health/ready/route.ts` | Readiness probe. 200 if `SELECT 1` round-trips to Postgres; 503 otherwise. | +| `web/db/migrations/meta/_journal.json` | Drizzle journal — authoritative, covers 0000..head. `drizzle-kit migrate` is a no-op against a DB at head, and applies everything against a fresh DB. | + +--- + +## Supported Postgres + +**Target: 16.x. Accepted: 15–17.** + +The schema uses UUID, JSONB, text arrays, correlated subqueries, and +`SELECT DISTINCT ON` — all stable since 13. We pin the reference +compose to `postgres:16` because that's the current LTS. CI should run +against the minimum version we claim to support. + +The `postgres` node driver and Drizzle are version-agnostic across +this range. + +--- + +## Environment contract + +Single image, differs only by environment variables. + +| Variable | Required | Purpose | +|----------|----------|---------| +| `DATABASE_URL` | yes | `postgresql://user:pw@host:port/db`. Never baked into the image. | +| `NODE_ENV` | yes (runtime) | `production` in the runner image. | +| `PORT` | no | Default `3000`. | +| `HOSTNAME` | no | Default `0.0.0.0` so the container accepts external traffic. | +| `NEXT_TELEMETRY_DISABLED` | no | Set by the image; can be unset. | + +Anything secret is read on first request from the process environment. +Nothing gets compiled into the bundle. + +--- + +## Build + +### Local (single-arch) + +```bash +cd web +docker build --target runner -t wheretf/web: . +docker build --target migrator -t wheretf/migrate: . +``` + +### Multi-arch (done by CI) + +GitHub Actions (`.github/workflows/ci.yml`) builds both stages for +`linux/amd64` and `linux/arm64` on every push to `main` and every +release tag, then publishes to GHCR: + +- `ghcr.io/ndemarco/wheretf/web:sha-` (always) +- `ghcr.io/ndemarco/wheretf/web:latest` (on `main`) +- `ghcr.io/ndemarco/wheretf/web:v` (on release tags) +- `ghcr.io/ndemarco/wheretf/migrate:*` — same tag schema. + +Manual equivalent if you need it locally: + +```bash +docker buildx create --use --name wtf-builder +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target runner \ + -t ghcr.io/ndemarco/wheretf/web: \ + --push ./web +``` + +Tag with the git short-sha. Avoid `:latest` for prod. + +--- + +## Run + +### End-to-end with compose (simplest — good for smoke tests) + +```bash +docker compose up --build -d +# http://localhost:3000 +docker compose down -v # stop + wipe the local DB volume +``` + +Orchestration sequence: +1. `postgres` starts, waits for healthy. +2. `migrate` runs `drizzle-kit migrate` against `DATABASE_URL`, exits 0. +3. `app` starts only after `migrate` exits successfully. + +### Manual / deploy-system path + +```bash +# 1. Provision a Postgres DB and put DATABASE_URL somewhere secret. + +# 2. Run the one-shot migration task. +docker run --rm \ + -e DATABASE_URL="postgresql://..." \ + ghcr.io/ndemarco/wheretf/migrate: + +# 3. Start the app (scale to N replicas as needed). +docker run -d \ + -e DATABASE_URL="postgresql://..." \ + -p 3000:3000 \ + ghcr.io/ndemarco/wheretf/web: +``` + +Roll forward: run the new migrator image, then roll the app replicas. +Roll back: point the app at the old image. Schema rollback needs a +separate down-migration strategy — not covered here. + +--- + +## Health endpoints + +- `GET /api/health` — liveness. 200 unconditionally. Use for container + restart policies (`HEALTHCHECK` in the Dockerfile already does). +- `GET /api/health/ready` — readiness. 200 when the app can execute + `SELECT 1`; 503 otherwise. Use to gate load-balancer traffic and + rolling deploys. + +Any deploy system that rolls replicas should poll `/ready` before +marking a new container healthy and before removing an old one. + +--- + +## Dev → prod migration strategy + +Migration authority is the drizzle journal at +`web/db/migrations/meta/_journal.json` plus the numbered `.sql` files +next to it. The deploy flow: + +1. CI builds the `migrator` image at the same git sha as the `runner` + image. +2. Deploy system runs the migrator as a one-shot task against the prod + `DATABASE_URL`. Blocks on exit 0. +3. Deploy system rolls the `runner` image, gated on readiness. + +There is no entrypoint-migrate in the runner image — migrations are +always a separate task so scaled replicas don't race each other. + +For catastrophic rollback, `pg_restore` from the last pre-deploy dump. +The prod DB should take automated dumps at least hourly. + +--- + +## Environments + +| Environment | Database | Auth | Purpose | +|-------------|----------|------|---------| +| Local dev | Local Postgres via `docker-compose.dev.yml` | None (to come) | Development, manual testing | +| CI | Ephemeral Postgres service container | None | Automated tests | +| Staging | Homelab Postgres (separate DB) | OIDC (to come) | Pre-prod smoke | +| Production | Homelab Postgres (prod DB) | OIDC (to come) | Live app | + +Same image across staging and prod — they differ only in +`DATABASE_URL` and (eventually) auth config. + +--- + +## Logging + +App logs go to stdout as newline-delimited text (default Next.js +formatting). Deploy system is responsible for shipping and retention. +Nothing app-side configures files, rotation, or remote sinks. + +--- + +## Performance notes + +- Standalone output + alpine base → runtime image ~180 MB. +- BuildKit cache mounts for `npm ci` and `.next/cache` keep warm + rebuilds under a minute for small diffs. +- Single process per container. Scale horizontally for load. + Multi-replica is safe once auth + DB-backed sessions land. + +--- + +## Future work — TODOs so the deploy system knows what's coming + +These are **planned, not implemented.** Deployment as described above +works without them. When any one of them lands, this doc and the +deployment system both get revisited. + +### Identity provider on the homelab (TODO) + +The homelab currently has **no IdP deployed**. Before authentication +can ship, one has to be stood up and maintained. + +Scope for that effort (separate project, separate plan cycle): +- Evaluate Authentik, Keycloak, Zitadel. Pick one. +- Deploy (Proxmox LXC or container), put behind Caddy, back with the + homelab Postgres VM or its own DB. +- Automate lifecycle with Ansible so it's reproducible. +- Add monitoring + backups alongside existing services. + +Until that lands, WhereTF runs without auth and must not be internet- +reachable. + +### Authentication (TODO) + +Depends on the IdP decision above. Plan: + +- **App side**: Auth.js (next-auth v5) with an OIDC provider. DB-backed + sessions — new `users`, `sessions` tables, users identified by + `(auth_provider, auth_subject)` for provider portability. +- **Dev**: local "impersonate" login gated on + `NODE_ENV !== "production"` so dev doesn't require the IdP to be + running. +- **CSRF**: Auth.js covers `/api/auth/*`; our mutation routes get a + shared helper. + +### Authorization (TODO) + +Model: **org-scoped, role per user-org pair.** + +- New tables: `orgs`, `user_orgs (user_id, org_id, role)`. + Roles: `owner | admin | member | viewer`. +- Every authenticated request carries + `{ userId, currentOrgId, role }`, derived from session + an + "active org" cookie. +- **Per-org** tables: `modules`, `locations`, `inserts`, `assignments`, + `templates`, `template_versions`, `co_storability`. +- **Global** tables: `items`, `item_aspects`, `item_parameter_values`, + `aspects`, `parameters`, `standards`, `designations`, `categories`. + Items are deliberately shared — see project memory on the global + catalog vision. +- Enforcement starts application-layer (every repo method takes + `{ orgId }`), moves to Postgres RLS when the threat model justifies + the complexity. + +### API access + rate limiting (TODO) + +Two surfaces: + +1. **Internal** — session-cookie auth, CSRF for mutations. + Generous per-user limits (e.g. 60 rps burst, 300 rpm sustained). +2. **External** — API keys. New table `api_keys` with hashed keys, + scopes, per-key rate limits tied to the org's plan (subscription + hook). + +Limiter: token bucket, sliding window. In-memory in dev; Redis (or +similar) in prod. + +Middleware lives in `web/middleware.ts`, intercepts `/api/*`, runs +auth + rate-limit + org hydration before the route handler. Exempts +`/api/health*`. + +### Multi-tenancy migration (TODO, depends on auth + authz) + +Execution order when picked up: + +1. Migration adds `users`, `orgs`, `user_orgs`, `sessions`. Adds + nullable `org_id` on every per-org table; backfills existing rows + to a "default" org; follow-up migration flips NOT NULL. +2. Repo refactor: every org-scoped method takes `{ orgId }`. + Integration test with two orgs enforces isolation per repo. +3. API middleware populates request-local org context. +4. Org switcher UI. +5. Items stay global. Write-heavy catalog paths audit into the + existing `transactions` table. +6. `orgs.plan` → rate limits + feature flags. Stripe (or whatever) + webhook updates it. + +None of the above blocks the deploy work. Ship the image now; layer +auth + tenancy in when the IdP is ready. diff --git a/specification/item-maintenance.md b/specification/item-maintenance.md new file mode 100644 index 0000000..9113a79 --- /dev/null +++ b/specification/item-maintenance.md @@ -0,0 +1,148 @@ +# Item Maintenance — Use Cases + +These use cases define how users create, find, edit, and manage items in WhereTF. They inform the item maintenance UX design. + +--- + +## To address + +1. Adding a family or group: I have a set of M3 SHCS in lengths 5, 6, 10, 12, 14, 20 mm. All have the same properties. + +## UC-1: Create a New Item + +Trigger: User acquires a new type of item (just bought, just received, found in a drawer). + +Precondition: None. Items can be created standalone or during assignment. + +Postcondition: Item exists in the system. Optionally assigned to a location. + +Edge cases: +- User creates a vague item ("resistors") — allowed, but the system should encourage specificity when disambiguation becomes necessary (see UC-7). +- Duplicate detected — user can merge with existing (see UC-5) or proceed with distinct item. + +--- + +## UC-2: Find an Item + +Trigger: User wants to locate an item or check if it exists. + +Precondition: At least one item exists. + + +Postcondition: User sees the item and its locations, or confirms it doesn't exist. + +Edge cases: +- No results — system offers to create a new item with the search term as the name. +- Item exists but is unassigned — shown with "unassigned" status, offer to assign. + +--- + +## UC-3: Edit an Item + +Trigger: User notices incorrect or incomplete information while browsing, searching, or assigning. + +Precondition: Item exists. + + + +Postcondition: Item definition updated everywhere it appears. + +Edge cases: +- Renaming to match an existing item — system warns and offers merge (UC-5). +- Editing an item that has assignments — fine, no confirmation needed. The item identity hasn't changed. + +--- + +## UC-4: Split an Item + +Trigger: User realizes an existing item is actually two or more distinct things. Common as collections grow and initial vague definitions need refinement. + +Precondition: Item exists, typically with multiple assignments. + + + +Postcondition: Original item is refined. New item(s) exist with their own assignments. No assignments are orphaned. + +Edge cases: +- User splits but doesn't reassign all locations — system blocks completion until all assignments are resolved. +- Single-assignment item — split is technically allowed (the item definition changes and a new item is created) but unusual. + +--- + +## UC-5: Merge Items + +Trigger: User discovers duplicates — two items that are actually the same thing, possibly entered at different times with different names or parameter detail. + +Precondition: Two or more items exist that represent the same real-world thing. + + +Postcondition: One item remains with all assignments. Duplicates removed. + +Edge cases: +- Merged items were at the same location — assignments collapse (same item, same location = one assignment). +- Parameter conflicts — surviving item's definition may need editing to capture the union of information. + +--- + +## UC-6: Delete an Item + +Trigger: User no longer has or tracks this type of item. + +Precondition: Item exists. + + + +Postcondition: Item and all its assignments no longer exist. + +Edge cases: +- Item with no assignments — delete immediately (with undo toast, not confirmation dialog). +- Accidental delete — undo window (consistent with project's undo+notify pattern). + +--- + +## UC-7: Progressive Refinement + +Trigger: User adds a new item that is too similar to an existing one. The system or user recognizes that one or both definitions need more detail to be distinguishable. + +Precondition: Two or more items exist with overlapping names or parameters. + + + +Postcondition: Items are unambiguous. Assignments are correct. + +Edge cases: +- User declines to refine — allowed. The system notes the ambiguity but doesn't block. Items can coexist with similar names if the user accepts it. + +--- + +## UC-8: Bulk Parameter Edit + +Trigger: User needs to change the same parameter across many items at once. Common during data cleanup, reclassification, or after discovering a systematic error. + +Precondition: Multiple items share a parameter that needs changing. + + + +Postcondition: All selected items updated. Assignments unchanged. + +Edge cases: +- Change creates ambiguity between items — system warns (see UC-7). +- Change affects items at many locations — fine, item identity hasn't changed, just metadata. +- User wants to change different values on different items — that's not bulk edit, that's individual edits. This use case is for uniform changes across a filtered set. +- Partial application — user deselects some items from the batch. Only selected items are changed. + +--- + +## Cross-Cutting Concerns + +### Undo +All destructive operations (delete, merge, split reassignment) follow the project's undo+notify pattern: action executes immediately, toast with undo action appears, auto-dismisses after a timeout. No confirmation dialogs. + +### Item-Location Navigation +From any item view, the user can navigate to any of its assigned locations. From any location view, the user can navigate to the item and see all its other locations ("Also at" links). + +### Future: AI-Assisted Creation +Item creation will eventually leverage AI for fuzzy matching, auto-categorization, and parameter extraction from photos or text descriptions. The manual flow defined here is the foundation — AI assists but doesn't replace it. + +### Future: Fuzzy Dedup +The system will eventually proactively surface potential duplicates for merge consideration. The merge flow (UC-5) is designed to support both user-initiated and system-suggested merges. diff --git a/specification/item-management-design.md b/specification/item-management-design.md new file mode 100644 index 0000000..0cfdbe6 --- /dev/null +++ b/specification/item-management-design.md @@ -0,0 +1,173 @@ +# Item Management — UI/UX Design + +How users browse, create, edit, and organize items in WhereTF. + +--- + +## Page Structure + +Separate page from the storage navigator, accessible from the sidebar menu at `/items`. The item editor is reachable from any item reference in the app (grid cells, assignment lists, search results). + +Three-panel layout: +- **Left panel** — search bar + filter pills + category filter +- **Center panel** — item grid (primary workspace) +- **Right panel** — selected item detail, aspect management, filter-from-value interaction + +--- + +## Navigation + +Sidebar icon rail is the root layout — shared across all pages. Routes: +- Modules icon → `/navigator` +- Items icon → `/items` +- Templates → `/templates` (future) +- Activity → `/activity` (future) + +--- + +## State in URL + +Active filters, sort column/direction, and search query are reflected in URL query parameters. Enables sharing and bookmarking filtered views. Example: `/items?filter=thread_diameter:M3,head_type:SHCS&sort=name:asc&q=stainless` + +--- + +## Left Panel: Search and Filters + +### Search + +Text search bar at the top. Searches across item name, description, and parameter values. Executed at the database level. Results narrow the grid in real time (2+ characters). + +### Filter Pills + +Below the search bar. Each pill shows `Parameter: Value` (e.g., `Thread diameter: M3`). Pills are added from the detail panel (see Right Panel). Multiple pills combine with AND logic. Click X on a pill to remove it. Removing all pills returns to the unfiltered view. All filtering happens server-side — pills translate to query parameters sent to the API. + +Dynamic facet counts: when pills are active, the system indicates how many items match each remaining filter option. Prevents dead-end filtering. + +### Category Filter + +Below the pills. Shows system categories with item counts. Click a category to add it as a filter pill (`Category: Fasteners`). Counts update as other filters are applied. + +--- + +## Center Panel: Item Grid + +The primary workspace. Items as rows, parameters as columns. Built with TanStack Table (headless — we control rendering). + +### Columns + +- **Name** column is always first, always frozen (does not scroll horizontally). +- **Primary category** icon column, frozen after name. +- **Dynamic parameter columns** — determined by context. Algorithm and calculation method deferred. Initially show a fixed set of common columns; dynamic adaptation is a future enhancement. +- **Column chooser** — button to manually show/hide/reorder columns. User column choices override any automatic selection. +- Horizontal scroll for overflow columns. + +### Inline Editing + +Click a cell to edit in place. The editor type matches the parameter's data type: +- Numeric: number input with unit label +- Text: text input +- Boolean: checkbox +- Enum: dropdown with valid options + +Changes save on blur or Enter. Esc cancels. + +### Row Selection + +Click a row to select it and populate the detail panel. Ctrl+click for multi-select. Selected rows have accent highlight. Multi-select shows "N items selected" in detail panel (bulk operations deferred). + +### Sorting + +Click a column header to sort. Click again to reverse. Sort indicator arrow in header. Sort is executed server-side. + +--- + +## Right Panel: Item Detail + +Shows the full description of the selected item. This is the only place where filter-from-value interaction occurs. + +### Header + +Item name (editable inline), description (editable inline). + +### Categories + +Tags/chips for each category. One can be starred as primary. X to remove. "+ Add" button opens a category picker dropdown. + +### Aspects + +Each applied aspect as a collapsible section. Section header shows aspect name + completeness indicator: `Thread (2/4)` — filled/total params. Color: green=complete, orange=partial, gray=empty. X button to remove aspect. + +Inside: parameter rows with: +- Parameter name, value (editable), unit label +- **Filter funnel icon** — clicking adds a `Param: Value` pill to the left panel. Only shown when value is non-empty. This is the only mechanism for adding parameter filters. + +Below aspects: "+ Apply Aspect" button with dropdown of available aspects. + +### Standalone Parameters + +Section below aspects. Same row layout (name, value, unit, filter icon). "+ Add Parameter" button to attach a parameter definition. + +### Locations + +"Stored at" section. Location paths as clickable links (navigate to `/navigator` with that location focused). Assignment type badge (placed/provisional). + +### Actions + +- Delete item — immediate with undo toast + +--- + +## Item Creation + +Floating action button (`+ Item`) bottom-right of center panel. Creates a new item, selects it, focuses the name field. Item saves immediately with just a name. All other fields populated via detail panel or inline grid editing. + +--- + +## Data Fetching + +### Rich Item Endpoint + +`GET /api/items` returns items with taxonomy data included — categories, applied aspects, and parameter values. Supports query parameters: +- `q` — text search across name, description, parameter values +- `filter` — parameter value filters (AND logic) +- `sort` — column and direction +- `category` — category filter + +All filtering, searching, and sorting is executed at the database level. + +### Mutations + +Individual API calls for each mutation (update name, set parameter value, add category, apply aspect, etc.). Optimistic updates in the UI. + +--- + +## Cross-Cutting Patterns + +### Completeness Indicators + +Aspect sections show filled/total parameter count. Supports "get items in fast, refine later." + +### No Right-Click + +All actions via visible UI elements. See [ui-paradigms.md](ui-paradigms.md). + +### Undo, Not Confirm + +Destructive actions execute immediately with undo toast. + +### Item Reachability + +Every item reference in the app links to `/items?selected={id}`. + +--- + +## Deferred + +- Split/merge items (UC-4, UC-5) +- Bulk parameter edit across selected items (UC-8) +- Progressive refinement / dedup detection (UC-7) +- AI-assisted item creation +- Saved/named views (filter + column + sort configurations) +- Multi-item comparison view in detail panel +- Dynamic column prevalence calculation +- Pagination / virtual scrolling strategy diff --git a/specification/item-parametric-model.md b/specification/item-parametric-model.md new file mode 100644 index 0000000..073e986 --- /dev/null +++ b/specification/item-parametric-model.md @@ -0,0 +1,307 @@ +# Item Parametric Model — Design Discussion + +## First Principle + +**Parameters are elemental physical properties, globally scoped, with no domain semantics.** Pitch is pitch — whether on a propeller, a screw thread, or a pipe fitting. Length is length. Mass is mass. A parameter definition describes a measurement, not a use case. + +Domain constraints (pipes don't come in 80 TPI) are properties of the domain, not of the parameter. Those constraints live in standards. Search narrowing by domain uses categories and aspects as filters layered on top of global parameter queries. + +**Designations are the primary affordance, not standards.** Users pick "M3" or "#8-32" or "0603" — they don't pick "ISO 261" or "JEDEC." Standards are backstage plumbing: they power the lookup tables that resolve a designation to parameter values. The standard name is available as metadata (for reference, filtering, disambiguation) but is never required knowledge for ordinary use. + +--- + +## The Pattern + +Physical goods are manufactured to standards. A standard defines a table of valid designations, each mapping to a set of parameter values. Picking a designation determines those values. + +Examples: +- UNC threading: designation "#8-32" → pitch=32 TPI, major_dia=0.164", minor_dia=0.1302" +- SMD packages: designation "0603" → length=1.6mm, width=0.8mm, height=0.45mm (NOTE: SMD packages exist in both imperial and metric designations — there's a 0603M package also) +- Wire gauge: designation "18 AWG" → diameter=1.024mm, resistance=20.95 Ω/km, ampacity=16A (NOTE: Wire gauges also exist in metric, indicating cross-sectional area in mm²) +- Bearings: designation "6201" → bore=12mm, OD=32mm, width=10mm +- O-rings: designation "AS568-210" → ID=19.99mm, cross_section=3.53mm + +The pattern: **standard + designation → parameter values**. + +--- + +## Core Concepts + +### Parameter Definition + +The atomic unit. A physical measurement: name, dataType, unit. System-wide, unique by name. No domain semantics, no aspect scoping. + +Examples: pitch (numeric, mm/thread), length (numeric, mm), major_diameter (numeric, mm), resistance (numeric, Ω/km), tapered (boolean), drive_type (enum: cross, slotted, hex, square, star, spanner, ...), drive_system (enum: Phillips, Pozidriv, Torx, Robertson, ...), drive_size (numeric, mm). + +Each parameter definition declares a **canonical unit** — the single unit used for storage and comparison, always SI. All values are normalized to SI internally, enabling global queries ("all items with length < 10mm") without unit conversion at query time. + +Parameters have optional constraints (min, max, enumValues) that reflect physical limits of the measurement itself, not domain-specific valid ranges. + +### Value Representation + +Numeric parameter values carry three fields: + +``` +{ + "value": 12.7, // canonical, in the parameter's declared unit (mm) + "source_value": "1/2", // as entered or as defined by the standard + "source_unit": "in" // the original unit system +} +``` + +**`value`** is what the system queries, compares, and indexes. It is always in the parameter's canonical unit. + +**`source_value` + `source_unit`** preserve the original representation. "1/2 inch pipe" displays as "1/2″" — not "12.7mm" — because that's how the domain identifies it. The source fields are display-only; they never participate in search or comparison. + +This applies everywhere parameter values appear: +- `item_parameter_values.value` (JSONB) stores the compound representation +- `standard_designations.values` (JSONB) stores compound values per parameter in the lookup table + +Trade designations that aren't real measurements (pipe nominal sizes, wire gauge numbers) are handled by making the designation string itself the human label. The parameter values behind it are the actual physical measurements. "1/2 inch pipe" is the designation; the `values` contain OD=0.840", ID=0.622" in canonical units with source representations preserved. + +Non-numeric parameters (boolean, text, enum) store `value` only — no unit conversion applies. + +### Unit Conversion + +Each parameter definition declares a canonical unit (always SI). Source values in other units must be converted before storage. Conversions fall into three categories: + +**Direct scaling** — multiply by a constant. +- inches → mm: `value * 25.4` +- feet → m: `value * 0.3048` +- ounces → grams: `value * 28.3495` +- mil (thou) → mm: `value * 0.0254` + +**Inverse conversion** — the source unit measures the reciprocal of the canonical unit. +- TPI (threads per inch) → mm/thread: `25.4 / value` +- Gauge numbers (AWG) → mm diameter: lookup table (non-linear, no formula) + +TPI is a count-per-length unit; the canonical SI representation of pitch is distance-per-thread (mm). A #8-32 screw at 32 TPI stores `pitch.value = 0.79375` (mm/thread), with `source_value = "32"`, `source_unit = "TPI"`. Searching "pitch < 1mm" returns fine-thread fasteners regardless of whether they were entered as TPI, mm, or metric pitch. + +**Non-linear formula** — a formula exists but it is not a simple ratio. +- AWG → diameter: `diameter_mm = 0.127 × 92^((36 - n) / 39)` (geometric progression) +- Cross-section and resistance derive from diameter. +- AWG gauge numbers are not measurements — they are designation labels. But unlike truly arbitrary designations, the underlying values are computable. + +The parameter definition stores which conversion category applies. The system must know how to round-trip: given `source_value` + `source_unit`, compute `value`; given `value` + `source_unit`, recover `source_value` for display. For inverse conversions, the formula is its own inverse. For lookup-only, both directions require the table. + +### Aspect + +A domain grouping that references parameter definitions and optionally contains standards. Aspects define the interface — "these are the parameters that describe this facet of an item." + +Examples: +- "Machine Screw Threading" → parameters: pitch, major_dia, minor_dia, thread_angle, class_of_fit +- "Pipe Threading" → parameters: pitch, major_dia, minor_dia, thread_angle, tapered +- "SMD Package" → parameters: length, width, height, lead_count +- "Fastener Drive" → parameters: drive_type, drive_system, drive_size +An aspect can have zero standards (freeform — user enters values manually) or multiple standards (user picks one, values cascade from lookup). + +### Standard + +A named classification system belonging to an aspect. Carries a lookup table of designations → parameter values. The standard provides the domain constraints — which combinations of values are valid. + +Examples: +- Standard "UNC" (belongs to "Machine Screw Threading") → designations: #4-40, #6-32, #8-32, ... +- Standard "NPT" (belongs to "Pipe Threading") → designations: 1/8"-27, 1/4"-18, 1/2"-14, ... +- Standard "ISO 261" (belongs to "Machine Screw Threading") → designations: M3x0.5, M4x0.7, ... +- Standard "JEDEC" (belongs to "SMD Package") → designations: 0603, 0805, SOT-23, SOT-223, ... +- Standard "Phillips" (belongs to "Fastener Drive") → designations: #0, #1, #2, #3, #4 +- Standard "Torx" (belongs to "Fastener Drive") → designations: T6, T8, T10, T15, T20, T25, ... + +A standard's parameters are a subset of its parent aspect's parameters. The standard may not cover all of them — uncovered parameters remain user-entered. + +### Designation + +A specific entry within a standard. Maps to concrete parameter values. + +- Belongs to one standard +- Has a display name (the designation string) — this is the trade label, which may not be a real measurement (e.g., "1/2 inch" for pipe nominal size) +- Carries a values map: parameter_definition_id → compound value ({ value, source_value, source_unit }) + +--- + +## Hierarchy + +``` +Aspect: Machine Screw Threading + ├─ parameters: pitch (mm), major_dia (mm), minor_dia (mm), thread_angle (°), class_of_fit (enum) + ├─ Standard: UNC + │ ├─ #4-40 → { pitch: {v:0.635, src:"40", su:"TPI"}, major_dia: {v:2.845, src:"0.112", su:"in"}, ... } + │ ├─ #8-32 → { pitch: {v:0.794, src:"32", su:"TPI"}, major_dia: {v:4.166, src:"0.164", su:"in"}, ... } // 25.4/32 = 0.794 mm/thread + │ └─ ... + ├─ Standard: UNF + │ └─ ... + └─ Standard: ISO 261 + ├─ M3x0.5 → { pitch: {v:0.5, src:"0.5", su:"mm"}, major_dia: {v:3.0, src:"3.0", su:"mm"}, ... } + └─ ... + +Aspect: Pipe Threading + ├─ parameters: pitch (mm), major_dia (mm), minor_dia (mm), thread_angle (°), tapered (boolean) + ├─ Standard: NPT + │ ├─ 1/2"-14 → { pitch: {v:1.814, src:"14", su:"TPI"}, major_dia: {v:21.336, src:"0.840", su:"in"}, tapered: true } + │ └─ ... + └─ Standard: BSPT + └─ ... + +Aspect: SMD Package + ├─ parameters: length (mm), width (mm), height (mm), lead_count (numeric) + ├─ Standard: JEDEC + │ ├─ 0603 → { length: {v:1.6, src:"0603", su:"JEDEC"}, width: {v:0.8}, height: {v:0.45}, lead_count: {v:2} } + │ ├─ SOT-23 → { length: {v:2.9}, width: {v:1.3}, height: {v:1.0}, lead_count: {v:3} } + │ └─ ... + └─ Standard: IPC + └─ ... + +Aspect: Fastener Drive + ├─ parameters: drive_type (enum), drive_system (enum), drive_size (mm) + ├─ Standard: Phillips + │ ├─ #0 → { drive_type: "cross", drive_system: "Phillips", drive_size: {v:2.0, src:"#0"} } + │ ├─ #2 → { drive_type: "cross", drive_system: "Phillips", drive_size: {v:5.0, src:"#2"} } + │ └─ ... + ├─ Standard: Torx + │ ├─ T10 → { drive_type: "star", drive_system: "Torx", drive_size: {v:2.74, src:"T10"} } + │ ├─ T25 → { drive_type: "star", drive_system: "Torx", drive_size: {v:4.43, src:"T25"} } + │ └─ ... + └─ Standard: Pozidriv + ├─ #2 → { drive_type: "cross", drive_system: "Pozidriv", drive_size: {v:5.0, src:"#2"} } + └─ ... +``` + +Note: "pitch" and "major_dia" appear in multiple aspects. They are the same parameter definitions — not copies. Values on items are stored once per parameter per item, globally scoped. Canonical units are SI (mm for pitch as distance between threads, mm for diameters, ° for angles). Source representations preserve the original domain notation (TPI, inches) for display. + +--- + +## Proposed Schema + +``` +standards + id uuid PK + name text UNIQUE NOT NULL -- "UNC", "ISO 261", "AWG" + aspect_id uuid FK → aspects -- the aspect this standard belongs to + description text + created_at timestamp + updated_at timestamp + +standard_parameters + id uuid PK + standard_id uuid FK → standards + parameter_definition_id uuid FK → parameter_definitions + role text NOT NULL -- "key" | "derived" | "info" + sort_order integer + UNIQUE(standard_id, parameter_definition_id) + +standard_designations + id uuid PK + standard_id uuid FK → standards + designation text NOT NULL -- "#8-32", "M3x0.5", "0603" + values jsonb NOT NULL -- { param_def_id: { value, source_value, source_unit }, ... } + metadata jsonb -- notes, aliases, cross-references + UNIQUE(standard_id, designation) + +item_standards + id uuid PK + item_id uuid FK → items + standard_id uuid FK → standards + designation_id uuid FK → standard_designations -- NULL if non-standard/custom + is_custom boolean DEFAULT false -- true if user overrode derived values + created_at timestamp + UNIQUE(item_id, standard_id) +``` + +Existing tables unchanged: +- `parameter_definitions` — atomic specs, globally scoped +- `aspects` — domain groupings referencing parameters +- `aspect_parameters` — join: which parameters belong to which aspects +- `item_aspects` — aspect applied to item +- `item_parameter_values` — actual values, stored per item per parameter as compound representation ({ value, source_value, source_unit }). Populated manually or from standard lookup. + +--- + +## User Flow + +### Applying an aspect with standards + +1. User selects item, clicks "Add Aspect" +2. Picker shows available aspects +3. User selects "Machine Screw Threading" +4. Aspect is applied. System shows its parameters with empty value slots. +5. System also shows available standards for this aspect: UNC, UNF, ISO 261 +6. User picks "UNC" → designation picker appears with searchable list +7. User picks "#8-32" → system fills derived parameter values from lookup +8. Uncovered parameters (class_of_fit) remain for manual entry +9. User can override any derived value — system flags it as custom + +### Applying a freeform aspect + +1. User selects "Physical Dimensions" +2. No standards available — empty parameter slots appear +3. User fills in length, width, height manually + +### Search + +- "All items with pitch < 10" → global parameter query, returns pipes and screws +- "All items with pitch < 10 in category Fasteners" → parameter + category filter +- "All items with Machine Screw Threading aspect and pitch < 10" → parameter + aspect filter +- "All UNC #8-32 items" → standard + designation filter + +--- + +## Access Control Boundaries + +The model must support tiered access at the data layer: + +- **Standard names, designation strings, parameter definition names**: low-privilege data. Required for search and identification. +- **Structured parameter values within a designation (the `values` JSONB)**: high-privilege data. The parametric breakdown is the catalog's deep value. +- **Full designation tables (all entries for a standard)**: must never be returned in a single API call. Pagination and per-account rate limiting required regardless of privilege tier. + +The `values` JSONB on `standard_designations` is a discrete, gatable field. The API layer can return designation records with or without it based on caller privilege. + +--- + +## Resolved Questions + +### 1. Parameter value storage for standard-derived values + +**Leaning Option B** — computed at read time from the designation reference. Lookup updates propagate automatically. Revisit in detail during implementation; may need caching or materialized views for performance. + +### 2. Compound designations + +Decompose in the data model. "M3x0.5x10" is two standards applied to one item: threading (M3x0.5) + geometry (10mm length). Reconstitute for display — the user sees "M3x0.5x10" but the system stores two separate standard/designation pairs. + +### 3. Standard families + +Flat with a domain tag. UNC and UNF both tagged "Unified Thread Standard" but no parent/child hierarchy. Can revisit if grouping becomes necessary. + +### 4. Designation aliases + +No alias table. AI handles fuzzy matching of "#8-32" / "8-32" / "#8-32 UNC" / "No. 8-32" at the search/input layer. + +### 5. Lookup table population + +Out of scope for this spec. Pipeline design (system seeds, admin entry, community contribution, bulk import) is a separate concern. + +### 6. Cross-standard equivalence + +No equivalence mappings in the data model. If cross-standard matching becomes necessary (e.g., "#8-32 UNC" ≈ "M4x0.7"), it belongs in the AI layer. + +--- + +## What This Means for Existing Code + +### Migration path + +- `aspects` and `aspect_parameters` unchanged +- New tables: `standards`, `standard_parameters`, `standard_designations`, `item_standards` +- `item_parameter_values` unchanged — both standards and aspects write to it +- Existing seed data: "Threading" aspect stays, gains standards beneath it. "Dimensions" stays freeform. + +### UI impact + +- Item detail: aspects now optionally show available standards with designation pickers +- Taxonomy admin: new Standards section for creating standards and managing lookup tables within aspects +- Search: global parameter queries + category/aspect narrowing + +### API impact + +- New endpoints: `/api/standards`, `/api/standards/[id]/designations` +- Item endpoints: apply standard + designation to item +- Search: filter by parameter values globally, narrow by category/aspect/standard diff --git a/specification/item-taxonomy.md b/specification/item-taxonomy.md new file mode 100644 index 0000000..b6a348f --- /dev/null +++ b/specification/item-taxonomy.md @@ -0,0 +1,141 @@ +# Item Taxonomy + +How WhereTF classifies, describes, and organizes items. The goal is fast, reliable identification — if you can't find it, it may as well not exist. + +--- + +## Design Principles + +Hierarchical classification forces a dimension choice. Filing bank statements by date then account, or account then date — you can't know which query you'll need. Parametric description avoids this by describing items as a set of observable properties. Search across any combination of parameters without committing to a hierarchy. + +Categories are useful for quick human scanning but fail at boundaries — a spork is neither spoon nor fork, an LED is both optical and electronic. Categories in WhereTF are lightweight visual tags, not structural classification. The real identity of an item is its parameters. + +Wrong is better than empty. Approximate categorization reduces large set scan cost but may introduce errors. + +--- + +## Category + +A broad, human-readable label used for visual grouping. Categories drive grid tile icons and color hints — a screw icon for fasteners, an SOIC glyph for ICs. + +- An item can have zero or more categories. +- One category may be marked primary. The primary category drives the visual representation on grid tiles. +- If no primary is set, no icon is shown. The cell still displays the item name. +- Categories are a fixed system list and should be broad and shallow. Regular users do not create categories. +- Categories are filterable in search but are not the primary search mechanism. The parametric data drives search; categories provide a quick narrowing filter. + +--- + +## Parameter + +A named property with a typed value. Parameters are the atomic unit of item description. + +Examples: +- Length: 14 mm +- Color: red +- Voltage rating: 50 V +- Thread direction: right (enum) +- RoHS compliant: true (boolean) + +### Parameter Definition + +A parameter definition prescribes the key name, data type, unit (if applicable), and constraints. Definitions are system-managed and reusable across items and aspects. + +- Name — the key (e.g., "Thread diameter", "Length", "Drive size") +- Data type — numeric, text, boolean, enum (pick from a list) +- Unit — optional, declares the measurement domain (mm, inches, volts, ohms). When a unit is declared, the parameter value is numeric and unit-aware. Entry in non-native units is supported with autoconversion (consistent with the storage model's unit handling). +- Default value — optional. Pre-filled when the parameter is added to an item via an aspect. The user can accept or change it. No inheritance, no update propagation — just a starting value. +- Constraints — optional restrictions on valid values: + - Enum values — a fixed list of valid options (e.g., Drive style: Phillips, Torx, Hex, Slotted) + - Numeric range — min/max bounds (e.g., Thread pitch: min 0.2, max 6.0) + - Required vs. optional — whether the parameter must have a value when the aspect is applied. Required means the system flags it as incomplete, not that it blocks saving. + +--- + +## Aspect + +An aspect is a reusable group of parameter definitions that describes one facet of an item. Aspects are the core normalization mechanism — they prescribe what parameters an item should have, ensuring consistent description across similar items. + +(The Charm/Odoo addon called this concept a "class." We use "aspect" to avoid collision with inventory management and programming terminology.) + +An aspect defines: +- Name — what facet it describes (e.g., "Thread", "Drive", "Head", "Package", "Material") +- Parameter definitions — the set of parameters belonging to this aspect, each with its data type, default value, and required/optional flag +- Description — what this aspect represents physically + +Aspects do not carry instance values. They define structure and defaults. Values are supplied when an aspect is applied to an item. + +### Applying Aspects + +An item gains parameters by applying one or more aspects. Each application copies the aspect's parameter definitions (with defaults) onto the item. The user fills in or adjusts the values. + +Multiple applications of the same aspect on one item (e.g., two Thread aspects on a pipe nipple) require a role to distinguish them. Role handling is deferred — the concept is noted here; the mechanism will be specified when pipe fittings and similar multi-aspect items are actively modeled. + +### Composition Example + +A machine screw: +- Aspect: Thread + - Thread system: metric + - Thread diameter: M3 + - Thread pitch: 0.5 (default: standard for M3) + - Thread direction: right (default) +- Aspect: Drive + - Drive style: hex + - Drive size: 2.5 mm +- Aspect: Head + - Head group: cylindrical + - Head name: socket head cap +- Aspect: Material + - Material type: 18-8 stainless steel + - Finish: black oxide +- Length: 10 mm (standalone parameter, not part of any aspect) + +An SMD resistor: +- Aspect: Electrical + - Resistance: 10 kΩ + - Tolerance: ±1% + - Power rating: 0.125 W +- Aspect: Package + - Package type: SMD + - Package code: 0805 + +### Standalone Parameters + +Not every parameter belongs to an aspect. Some parameters are properties of the whole item, not of one facet — like Length on a fastener, which is the item-level dimension that typically varies across a product family. + +### Aspects Are Suggestions, Not Constraints + +Aspects prescribe what parameters should exist, but the system does not block an item that is missing parameters or has extra ones. An item can: +- Have an aspect applied with some parameters left blank (incomplete but valid — flagged, not blocked) +- Have parameters that don't belong to any applied aspect (ad-hoc description) +- Have no aspects at all (fully ad-hoc, just loose parameters) + +Get items in fast, refine later. + +--- + +## Item Families and Matrix Expansion + +Deferred. Items that share parameters across a product family (e.g., M3 SHCS in lengths 5, 6, 8, 10, 12, 14, 16, 20) need a creation and management mechanism. Whether this is an explicit family entity or derived from shared parameter signatures — and how shared-parameter edits propagate — requires further design. + +The parametric system and aspects defined here are the foundation for whatever family mechanism is chosen. + +--- + +## Search + +Search is AI-driven. Users describe what they're looking for in natural language; the system interprets the query against the parametric data. + +The structure defined in this document — typed parameters, unit-aware values, aspects grouping related parameters — is what makes AI search effective. Without consistent, structured data, search degrades to fuzzy text matching. With it, the AI can resolve "M3 socket head" to Thread diameter=M3 + Head name=socket head cap, and "0805 resistor under 100kΩ" to Package code=0805 + Resistance < 100kΩ. + +Categories provide an additional narrowing filter but are not the primary search axis. + +Search design is specified separately. + +--- + +## Relationship to Odoo/ERP + +Odoo requires every product to belong to exactly one category. When items sync to Odoo, WhereTF maps the primary category (or derives one from applied aspects) to Odoo's single-category requirement. The parametric richness stays in WhereTF; Odoo gets the simplified view it needs. + +Integration details are specified separately. diff --git a/specification/location-tracker-ux-issues.md b/specification/location-tracker-ux-issues.md new file mode 100644 index 0000000..78fc1bd --- /dev/null +++ b/specification/location-tracker-ux-issues.md @@ -0,0 +1,215 @@ +# Location Tracker — UX Issues (living doc) + +Running list of issues and decisions for the `/modules` and `/modules/[id]` areas. Updated during review sessions. No fixes applied until explicitly approved. + +--- + +## Global / navigation + +### GN-1 — Left toolbar expand/collapse +- **Problem:** Toolbar shows icons only; menu item names not visible. +- **Decision:** Add expanded mode (icon + name) and collapsed mode (icons only). Toggleable. Default: expanded. Persisted to localStorage. + +### GN-2 — Breadcrumb trail +- **Problem:** No persistent location-path indicator as user navigates. +- **Decision:** Always-visible breadcrumb at top of main content area, using brief display form from storage-model.md §Display Formats (e.g., `MUSE 1 / A3`). + +--- + +## `/modules` (list page) + +### ML-1 — Module card editing mode +- **Problem:** Card fields are inadvertently editable. +- **Decision:** Whole card gated by edit mode. Entering edit mode reveals Save/Cancel + Delete. Outside edit mode, all fields are read-only. +- **Note:** Card is the canonical display of a module as a first-class object. All module-level affordances stay with the card. +- **TODO:** Add stats to card: `% occupied` (full locations / total locations), physical location hint (from metadata). + +### ML-2 — Module deletion (GitHub-repo pattern) +- **Problem:** Single-click "Delete" is too dangerous even with undo. +- **Decision:** Follow GitHub repo deletion UX. + 1. Delete button only visible in edit mode. + 2. Opens a dialog that first explains the module's contents: inserts placed, items assigned, locations to resolve. If non-empty, user must either *move* or *orphan* contents before proceeding. + 3. Once conditions met, user must type the module name to confirm deletion. +- **Orphan semantics:** affected items become **unassigned** (their assignment records are removed). The deletion is recorded in the transaction log so it's reversible via undo. + +### ML-3 — "Add a module" +- **Status:** Not actually missing. User was mistaken. `/modules/new` exists. No change. + +### ML-4 — Module editing lives on the detail page +- **Decision:** `/modules` cards are **read-only**. List + add only, no edit/delete affordances on list cards. All module editing (and deletion per ML-2) happens in the right panel of `/modules/[id]`. +- **TODO:** Stats still display on the list cards per ML-1 (% occupied, physical location hint) — just non-interactive. + +--- + +## `/modules/[id]` (module detail page) + +### MD-1 — Add level control +- **Problem:** No button to add a level when a module is open. +- **Open:** Is "add level" just incrementing `primaryDimensionCount` and materializing the new level location, or are levels first-class records created individually? **Deferred.** + +### MD-2 — Parent (module) vs. children (levels) distinction +- **Decision:** Module header on detail page is **non-interactive label text only**. All editing of the module itself happens in the right panel (per ML-4 revision). Levels are clearly the editable children of the module. + +### MD-3 — "1 level" copy on module header +- **Problem:** Ambiguous text. +- **Decision:** Eliminate the "N level" line from the module header on this page. The level list itself communicates the count. + +### MD-4 — Default level selection +- **Problem:** No level selected by default; user must click one to see anything. +- **Decision:** Auto-select a level on page load. + - Ideal: last-selected level for this module, persisted to localStorage (per-device). + - Fallback: first level. + +### MD-5 — Level rename and property editing +- **Problem:** Unclear how to edit a level's name (e.g., rename "3" to "Power supplies") or set its properties. +- **Decision:** Level editing lives in the **right panel** when a level is selected. Right panel also hosts module-level editing (per ML-4) and eventually stats + bulk actions. +- **Level properties (initial):** label, locationType (receptacle / fixed / leaf), interfaceTypeAccepted, description, notes. Same edit-mode gating as the module card. + +--- + +--- + +## `/templates` and `/templates/[id]` + +### TP-1 — "New template" button missing +- **Problem:** No entry point to create a template from the list page or detail page. `/templates/new` exists as a route. +- **Status:** Capturing; needs clarification. + +### TP-2 — Delete a template +- **Problem:** No affordance to delete a template. +- **Decision:** + - If the template is **unreferenced** (no insert or location points at any of its versions): hard delete allowed via the same GitHub-repo pattern (type-to-confirm). + - If the template is **referenced**: hide instead of delete. A hidden template stays usable for existing inserts/locations but does not appear in pickers for new ones. +- **Future:** Delete-with-move — on delete of a referenced template, offer to replace its usages with a different template before removing. +- **Schema impact:** add `templates.isHidden boolean default false`. Picker/listing code filters it out. Existing inserts/locations resolve their templateVersion as normal. + +### TP-3 — No way back to templates list from detail +- **Problem:** When viewing `/templates/[id]`, the only path back to the list is the sidebar menu button. Feels wrong. +- **Direction (asked by user):** How do other UIs handle this? + - **Breadcrumb** (GitHub, GitLab, admin dashboards): `Templates › Plano 3600`. The crumb itself is the back-nav. This matches what we just added on `/modules/[id]` (GN-2). + - **Back arrow in page header** (iOS, Notion): explicit `← Templates` button at top-left of the detail page. + - **Sibling list kept visible** (master-detail on tablets / Notion sidebar / Finder): the list is a persistent left column, the detail fills the right. Click a different item, the right updates. +- **Recommended MVP:** Breadcrumb (matches GN-2 and the conventions the user already agreed to). Add a back-arrow on narrow viewports as a bonus. +- **Bigger picture (new from user):** Consider a "template editor" mode layered over the list — view the list, select/edit a template, return to the list. This is the *master-detail* / *stacked-navigation* pattern (Slack channels, Gmail labels, Xcode settings). Needs its own spec pass; deferred until TP-1/TP-2 land. + +### TP-4 — Template editor: master-detail layout +- **Decision:** `/templates` becomes master-detail. List on the left, detail/editor on the right. Click a row → list stays visible, detail fills the right pane. URL reflects selection (`/templates?selected=`). +- **Scope:** Templates only. Items and modules keep their current patterns. +- **Supersedes TP-3:** With master-detail, there's no navigation-away, so the "how do I get back" problem disappears. +- **Detail route:** `/templates/[id]` remains for deep-linking but becomes a thin redirect to `/templates?selected=[id]`. + +--- + +--- + +## Inserts + +### IN-1 — No `/inserts` page +- **Problem:** Inserts are only manageable through the module that hosts them. No way to see all inserts across the system, no way to create an unplaced insert from the UI (API only). +- **Decision:** Add a left-menu item **Inserts** → `/inserts`. List should be filterable **by type** (template) and **by interface type** (e.g. plano-3600, gridfinity-42mm) so the user can find "where does this bin fit?". Supports browsing placed + unplaced inventory. +- **Open (secondary):** Master-detail layout like templates, or something else. + +### IN-3 — Module level UI conflates receptacle with its insert +- **Problem:** On `/modules/[id]` a level like MUSE:1 shows grid cells (A1..D6) directly, hiding the fact that those cells belong to a specific **insert** (a physical instance of Plano 3600). User owns a *stack* of Plano 3600s; each is a distinct insert with its own name, overrides, and contents. The current UI doesn't name the insert in the level view and doesn't frame overrides as "on this insert" vs. "on this receptacle". +- **Name ownership:** the insert's name is a property of the insert. The level's label is usually a sequential stub (1, 2, 3 or A, B, C). Renaming the insert *may* be offered as a convenience from the receptacle context, but authoritative edit happens on the insert itself. +- **Direction:** + - Module level header should read something like: **MUSE 1** · receptacle · holds *"construction screws"* (Plano 3600 Stowaway) + - Or show a breadcrumb on the grid: MUSE › 1 › *construction screws* (Plano 3600) + - Overrides (merge/divide/disable/restrict) applied to cells *inside an insert* are insert-scoped — should persist as `inserts.overrides`, not on the location. They travel with the insert when relocated. + - Offer "Remove insert" and "Replace insert" at the receptacle level. +- **Open:** For cells inside an insert we've been writing to `locations.{isDisabled,maxWidthMm,…}` which lives on the child location row. That works when the insert never moves, but the spec says overrides must travel with the insert. Needs the structured `inserts.overrides` JSONB format we deferred earlier. Punt to when we implement merge (which already has to deal with insert-scoped persistence). + +### IN-5 — "New insert" entry point +- **Problem:** `/inserts` has no way to create a new, unplaced insert. Today the only way to get an insert is through the Place Insert wizard on a module level. +- **Decision:** Add a `+ New` button on the `/inserts` list. Opens a small form (pick template, optional name) → creates an unplaced insert → auto-selects it in the master-detail pane. + +### IN-6 — Hide UID chrome on the inserts page +- **Problem:** Insert UID is shown both in the list row and above the detail header. It's internal scaffolding — users don't want to see it in normal use. +- **Decision:** Remove both. UID can resurface later as a dim footer detail on the insert page once RFID / label-printing workflows arrive. + +### IN-7 — Insert detail is the item↔insert central surface +- **Problem:** The insert detail page currently shows metadata only. The user wants it to be the *primary* place to see an insert's layout, assign/unassign items to cells, and apply overrides (merge/unmerge/disable/restrict/divide). Module detail stays as a where-is-it view. +- **Decision:** + - Insert detail renders the insert's cell grid (same renderer idea as module detail today). + - Click a cell → cell-detail side panel: assigned items CRUD, overrides (disable/restrict). + - Multi-select cells (Ctrl/Cmd-click) → Merge action in the selection summary. + - Single-cell actions on a merged cell → Unmerge. + - Single-cell "Divide…" → splits into named children. + - This supersedes the in-progress cell-edit affordances on the module detail page; module detail will eventually stay read-only on cells and link to the insert page for edits. +- **Open — layout:** + - Current `/inserts` is master-detail (list + detail). Adding a full grid + cell detail means the detail pane needs more horizontal room. + - Options: + - (a) Keep master-detail; the right pane grows and the grid scrolls horizontally as needed + - (b) Full-page detail when a row is selected (collapse the list into a small drawer/header) + - (c) Hide the list on narrow screens, side-by-side on wide +- **Open — module page overlap:** + - Keep the module detail's grid + cell controls, or strip cell interactions there and route users to the insert page for anything beyond viewing? +- **Supersedes:** IN-2/3 Merge and IN-2/4 Divide now live here. + +### IN-4 — Placement from the insert side +- **Problem:** Today placement is receptacle-first (go to a level, pick a template). User also wants insert-first: "I'm holding this Plano, find somewhere it fits." And also wants to **kick an insert out** from either side. +- **Direction:** + - On `/inserts` detail for an unplaced insert: a "Place in…" button that lists compatible receptacles (filter by interface type match + currently empty). + - If already placed, show "Move to…" offering the same picker, plus "Unplace" (kick out without replacement). + - On `/modules/[id]` at a receptacle level that holds an insert: "Remove insert" (= unplace) and "Replace insert" (= unplace + reopen placement flow). +- **Naming clarification (per user):** the compatibility name is the **interface type**. Insert template `interfaceTypeProvided` must match receptacle `interfaceTypeAccepted`. + +### IN-2 — Where do I edit an insert's overrides (merge / divide / disable / restrict)? +- **Problem:** No UI exists for any of the four override types (see storage-model.md §Override Types). Schema supports: + - `locations.mergedIntoId` for merge aliasing on module-scoped locations + - `locations.isDisabled` + `disableReason` for disable on module-scoped locations + - `locations.maxWidthMm/maxHeightMm/maxDepthMm/restrictReason` for restrict (MVP) + - `inserts.overrides` JSONB for insert-scoped overrides (unstructured today — no validator) +- **Gaps:** + - No API endpoint to apply an override to an insert + - No API endpoint to divide a location (materialize children) + - No UI for any of this +- **Direction:** Overrides should be editable from the cell detail panel (right pane) on the module detail page. Multi-select a range of cells → "Merge" shows up in the panel. Right-click or action menu on a single cell → "Disable" / "Restrict height" / "Divide". +- **Status:** Deferred. Big feature area; needs its own spec pass before implementation. Related to IN-1 (if inserts get a dedicated UI, insert-scoped overrides may live there too). + +--- + +--- + +## Navigation + +### NV-1 — Admin section in left menu +- **Problem:** Operations that structurally change the workshop (creating/removing modules, creating/removing/hiding templates) are mixed in with everyday navigation. +- **Decision:** Group admin-style entries in a distinct section (visually separated) in the left menu. At minimum: modules admin, templates admin. Maybe taxonomy admin belongs there too. +- **Open:** Does this mean separate routes (`/admin/modules`, `/admin/templates`) or the same routes with read/admin modes? Probably same routes, just the menu grouping communicates intent. + +--- + +## Place Insert flow + +### PI-1 — "Next" button is off-screen at bottom of template list +- **Problem:** On `/modules/[id]/levels/[levelId]/place-insert`, the Next button lives at the bottom of the template list. With more than a handful of templates, it's below the fold and feels undiscoverable. +- **Direction (likely):** Move primary action (Next / Place) to a sticky footer bar, or a fixed header action, independent of the scroll position of the list. + +--- + +### HX-1 — Assignment history (cross-cutting) +- **Problem:** there's no surfaced history of who/what lived where before the current state. "Previously at this receptacle," "this item used to be in A3," etc. Comes up per-receptacle (previous inserts), per-insert (previous receptacles), per-cell (previous items), per-item (previous locations). +- **Direction (deferred):** leverage the existing `transactions` log (already records beforeState/afterState for every mutation). Build a per-entity history view that filters the log by entity id and renders as a timeline. A clean history UX inherently enables undo — each transaction entry is a reversible delta. +- **Scope:** applies to assignments, inserts, cells, receptacles, items. Would replace the current ad-hoc per-feature undo plans (e.g. module delete cascade) with a single transaction-log-driven pattern. +- **Status:** future feature. Note it here so it doesn't get re-invented per-entity. + +### IN-8 — Smart subdivision label suggestions +- **Problem:** divide dialog accepts any comma-separated strings. User pointed out the template already knows enough to suggest the right terms. A drawer's front/back axis, a shelf's left/right, or a template-declared subdivision accessory (e.g. Akro-Mils 40716 divider → front + rear) all imply better defaults. +- **Direction:** + - If the cell's template version has a non-empty `subdivisionOptions` JSONB (already a schema field), populate a dropdown of those as the first UI offering. User picks one → children created with predeclared labels. "Custom…" route stays available. + - If no subdivision option exists: fall back to a heuristic based on cell orientation (aspect ratio + primary axis + template kind) to propose `left, right` vs `front, rear` vs `top, bottom` as the placeholder. User can still type anything. +- **Work:** backend already has the JSONB field, nothing to add there. Need: TS type for the JSONB shape, a small helper that picks a suggestion, UI refactor from single text input to "dropdown of presets + custom" component. +- **Status:** deferred — not trivial (heuristic wants thought, options schema wants a type, UI wants a real picker). + +--- + +## Cross-cutting open questions +- **MD-1** add-level semantics — refined: on a module, ISBAT insert a new + level *before* or *after* an existing level X. No drag-to-reorder + (too easy to foot-gun). Typically an uncommon operation since + module structure doesn't change often. +- **IN-2** override UX — all 4 done (Disable, Restrict, Merge, Divide) +- **IN-3** module level header now surfaces the insert (done) +- **IN-4** insert-first placement (done) +- **IN-8** smart subdivision label suggestions from template diff --git a/specification/project-intent.md b/specification/project-intent.md new file mode 100644 index 0000000..a25ffed --- /dev/null +++ b/specification/project-intent.md @@ -0,0 +1,38 @@ +# WhereTF — Project Intent + +R&D workshop item tracker. Users model their physical storage layout, catalog items, and get help storing, finding, and organizing their stuff. AI-assisted natural language cataloging is a feature layer, not the foundation — build core storage and item management first. Single-user for initial implementation. Designed for future multi-user, multi-tenant (users belong to orgs); item data will be global (shared across orgs) in the multi-tenant version. Not inventory management — no quantities, BOMs, or stock transfers. + +## Interaction Model + +GUI and AI each own different concerns: + +- **Storage layout definition** — GUI-first. Users build module/level/grid structures visually. Templates (e.g. Plano Stowaway 3600) accelerate setup. Minimal AI involvement. +- **Item cataloging** — AI-first. User describes items in natural language, AI structures into canonical form with deduplication. Goal: build a valuable item identity DB where network effects improve matching over time. +- **Item assignment** — AI-first. User says where they're putting something, or asks for a suggestion. AI considers access frequency (tracked implicitly via search/retrieve actions) and storage accessibility. +- **Search** — AI-first. Natural language queries, results displayed as a list with corresponding locations highlighted in the storage GUI. +- **Housekeeping** — Bulk reorganization ("defrag"): combine like items, suggest discards, move frequently-used items to accessible locations. +- **ERP integration** — Items link to ERP products (e.g. Odoo). WhereTF is the R&D complement, not a replacement for MRP/stock. + +## Domain Concepts + +- **Item** — what a thing *is*, independent of where it is. A type/category, not an instance or count. Thorough, structured item characterization is core to WhereTF — finding an item requires describing it well. Items are described by name and typed parameters organized by aspects (reusable parameter groups). See [item-taxonomy.md](item-taxonomy.md) for the classification system. Equivalent to a product in ERP. Items belong to WhereTF globally — as items are refined and improved, they benefit all users. Storage and assignments are per-org; items are shared. Future: private items as a paid feature. +- **Assignment** — connects an item to a location. Own entity, not a field on item or location. Either *placed* (specific leaf location, one per location unless co-storable) or *provisional* (at a location, position undetermined). Many assignments per item. Unassigned items and empty locations are both valid states. +- **Module** — a top-level, independent physical storage unit (cabinet, shelf, drawer unit). Never nested. Defines valid location path structures. +- **Template** — versioned blueprint for a storage product's layout (e.g. Plano Stowaway 3600 = 4×6 grid). Applied via inserts (receptacle locations) or directly (fixed locations). Instances pin to an applied version. +- **Insert** — a distinct physical object that occupies a receptacle and provides its own internal locations. Relocatable as a unit. +- **Interface type** — named physical contract governing insert/receptacle compatibility. Strictly validated on placement. +- **Location path** — hierarchical address within a module. Module names are short identifiers, not descriptions. Descriptions belong in metadata. + +## AI Agent Model + +Router/specialist pattern. Router classifies intent, delegates to specialists. Specialists have scoped access to domain operations. The system loops tool calls until a text response is produced. + +## Context Management + +Sessions track conversation history with token estimation. Context thresholds trigger compression — summarize the conversation, archive it, and continue in a new session with the summary as context. + +## What WhereTF Is Not + +- Not inventory management (no quantities, BOMs, stock transfers) +- Not MRP (no purchase orders, suppliers, lead times) +- Not a catalog (items are user-defined, not sourced from a product database — though network effects may build one over time) diff --git a/specification/storage-definition-design.md b/specification/storage-definition-design.md new file mode 100644 index 0000000..e719ce8 --- /dev/null +++ b/specification/storage-definition-design.md @@ -0,0 +1,461 @@ +# Storage Definition — UI/UX Specification + +Defines the workflows for creating and configuring physical storage structures: modules, levels, templates, and inserts. This is the setup phase — building the scaffold before items get assigned. + +Complements [storage-navigator-design.md](storage-navigator-design.md) (browsing and interacting with storage) and [storage-model.md](storage-model.md) (data model reference). + +--- + +## Scope + +In scope: +- Module CRUD (create, view, edit, delete) +- Level/drawer generation and per-level configuration +- Template CRUD and version publishing +- Insert creation and placement into receptacles +- SVG grid visualization as structural confirmation +- Associating templates with receptacle locations + +- Overrides: merge, divide, disable (grid-interactive operations) +- Continuous-dimension locations (louver panels, open shelves) + +Out of scope (covered elsewhere or deferred): +- Item assignment to locations (storage-navigator-design) +- Drag-and-drop insert relocation (future) +- Template community catalog (future) + +--- + +## Navigation + +Module management lives at `/modules`. Accessed from sidebar icon rail. + +``` +/modules — module list +/modules/new — module creation wizard +/modules/:id — module detail (level table + grid preview) +/modules/:id/levels/:id — level detail (insert config, grid view) +/templates — template list +/templates/new — template creation +/templates/:id — template detail + version history +``` + +--- + +## Module List (`/modules`) + +Card grid. Each card shows: +- Module name (prominent) +- Description (one line, truncated) +- Primary dimension summary: "11 levels" or "9 drawers" +- Occupancy bar — simple fill indicator (assigned locations / total leaf locations) + +**Actions:** +- Card click → navigate to module detail +- "New Module" button (top-right, prominent) → navigate to creation wizard + +**Empty state:** "No modules yet. Create your first storage module to start organizing." + +**Sort:** by name (default), by recently modified, by occupancy + +--- + +## Module Creation Wizard (`/modules/new`) + +Multi-step form. Not a modal — a full page. Modules are created infrequently, so a deliberate process is appropriate. + +### Step 1: Identity + +- **Name** — short identifier (e.g., "MUSE", "ALEX"). Required. +- **Description** — what this module physically is (e.g., "Red metal cabinet, 11 shelf levels, under workbench"). Optional. + +### Step 2: Primary Dimension + +- **Dimension label** — what are the top-level subdivisions called? Freeform text with suggestions: "level", "drawer", "shelf", "row", "bay". Required. +- **Count** — how many? Numeric input, minimum 1. Required. +- **Preview** — as the user types, show a vertical stack diagram of the generated levels with auto-labels. Labels follow the convention: sequential numbers (1, 2, 3...) by default. + +### Step 3: Level Configuration + +Table of the generated levels. Columns: +- **Label** — editable (default: "1", "2", "3"...) +- **Type** — dropdown: "receptacle" (default) or "fixed" +- **Notes** — freeform text, optional + +All levels start as receptacles. The user can change individual levels or multi-select + batch apply. + +**Batch operations** (via multi-select checkboxes): +- Set type (receptacle/fixed) for selected levels +- Set notes for selected levels + +### Step 4: Review & Create + +Summary card showing: +- Module name and description +- Dimension label and count +- Level configuration table (read-only) + +"Create Module" button. On success → navigate to module detail page. + +--- + +## Module Detail (`/modules/:id`) + +Two-panel layout within the main content area (sidebar remains). + +### Left: Module Info + Level Table + +**Module header** — name (editable inline), description (editable inline), dimension summary. + +**Level table** — all levels for this module. Columns: +- Label +- Type (receptacle / fixed) +- Insert (name of placed insert, or "—" if empty) +- Status (active / disabled + reason) +- Occupancy (filled / total leaf locations, or "—" if no sub-structure) + +Row click → selects level, updates right panel. + +**Actions on module:** +- Edit name/description (inline) +- Add levels (append to end) +- Delete module (immediate with undo toast — per ui-paradigms.md, no confirmation dialog) + +**Actions on level (via row selection or level detail):** +- Place insert (if receptacle and empty) +- Remove insert (if receptacle and occupied) +- Configure fixed structure (if fixed type) +- Disable / enable +- Delete level + +### Right: Level Preview + +When a level is selected: +- If the level has sub-structure (insert placed or fixed template applied) → **SVG grid preview** showing the position layout. Labels on axes (rows alpha, columns numeric). Cells show occupancy state (empty, occupied, disabled). Read-only in this context — clicking a cell does nothing here (that's the navigator's job). +- If the level is an empty receptacle → "No insert placed. Place an insert to define this level's internal structure." with a "Place Insert" button. +- If the level is an empty fixed location → "No structure defined. Apply a template to define this level's layout." with an "Apply Template" button. + +When no level is selected → "Select a level to view its layout." + +--- + +## Place Insert Flow + +Triggered from level detail when the user clicks "Place Insert" on an empty receptacle. + +### Step 1: Choose Template + +Searchable list of templates. Each row shows: +- Template name +- Type (fixed / parametric) +- Dimensions (e.g., "4 rows × 6 columns") +- Version number + +Click a template → shows a grid preview of the template's positions below the list. + +### Step 2: Configure (parametric templates only) + +If the template is parametric, the user specifies dimensions: +- Grid size (e.g., 6 × 4 for a Gridfinity baseplate) +- Constrained by template's min/max + +Live grid preview updates as dimensions change. + +Fixed templates skip this step. + +### Step 3: Name & Confirm + +- **Insert name** — defaults to template name + auto-incrementing number (e.g., "Plano 3600 #4"). Editable. +- Grid preview showing the final layout within the level +- "Place Insert" button + +On confirm: +1. Insert record created (references template + version) +2. Child locations generated from template positions +3. Receptacle's compatibility is set by cloning the template's interface data +4. Level preview updates to show the new grid +5. Notification: "Plano 3600 #4 placed in MUSE Level 3" + +--- + +## Apply Template to Fixed Location + +Similar to Place Insert, but for fixed locations. The template is applied permanently — no insert record, locations are created directly as children of the fixed location. + +Same step sequence (choose template → configure if parametric → confirm), but: +- No insert name (there's no insert object) +- Messaging reflects permanence: "Apply Template" instead of "Place Insert" +- Notification: "Gridfinity 6×4 applied to ALEX Drawer 3" + +--- + +## Template List (`/templates`) + +Table view. Columns: +- Name +- Type (fixed / parametric) +- Current version +- Dimensions (rows × columns for latest version) +- Instance count (how many inserts + fixed applications use this template) + +**Actions:** +- Row click → navigate to template detail +- "New Template" button → navigate to template creation + +**Empty state:** "No templates defined. Create a template to define reusable storage layouts." + +--- + +## Template Creation (`/templates/new`) + +Single-page form (not a wizard — templates are simpler than modules). + +**Fields:** +- **Name** — e.g., "Plano 3600 Stowaway". Required. +- **Description** — optional. +- **Type** — fixed or parametric. Required. +- **Rows** — number. For parametric: this is the default, with min/max constraints. +- **Columns** — number. Same as rows. +- **Min/Max rows** (parametric only) +- **Min/Max columns** (parametric only) +- **Row labels** — radio: alpha (A, B, C…) or numeric (1, 2, 3…). Default: alpha. Row and column labels must differ — validation prevents selecting the same type for both. +- **Column labels** — radio: numeric or alpha. Default: numeric. +- **Origin** — dropdown: top-left (default), top-right, bottom-left, bottom-right. Controls which corner the label sequence starts from. Changing origin reverses the label rendering order on affected axes (e.g., bottom-left → row A is at the bottom, column 1 is at the left). +- **Row dividers** — checkbox: fixed (checked) or removable (unchecked). Default: removable. Fixed means permanent physical dividers — merging across rows is blocked. Example: Plano 3600 row walls are molded plastic. +- **Column dividers** — checkbox: fixed or removable. Default: removable. Independent of row dividers. Example: Plano 3600 column dividers are removable tabs. +- **Unit system** — radio: imperial or metric. Default: metric. + +**Live preview** — SVG grid updates as the user changes any property. Shows labels on axes with correct origin ordering. Fixed dividers render as thick lines between cells. Origin marker (accent triangle) appears in the label gutter corner outside cells. + +"Create Template" button. Creates the template with version 1 containing the specified configuration. + +--- + +## Template Detail (`/templates/:id`) + +Three-panel layout: left panel (version history + instances), center panel (grid preview), right panel (properties). + +### Left Panel + +**Header** — template name (editable inline), description (editable inline), type badge. + +**Version History** — table of versions, most recent first: +- Version number +- Dimensions (rows × columns) +- Date published +- Instance count (inserts/fixed locations using this version) +- Active badge — one version is marked "active" (the default for new inserts) +- Remove action (×) — visible only on versions with 0 instances. Hides the version from the list; data remains in the database. + +Version rows are clickable. Clicking a version updates the center grid preview and right properties panel to show that version's configuration. + +**Instances** — list below version history. Each row shows: +- Checkbox (for batch operations) +- Insert name (or "fixed" for direct applications) +- Module name → level label +- Version badge — shows current version, highlighted if not on the selected version + +**"Apply v[X] to Selected"** button below the instance list. X is the currently selected version in the history table. Supports both upgrade (moving to a newer version) and downgrade (reverting to an older version). + +### Center Panel + +SVG grid rendering of the selected version's layout. Large cells (72px), 8px gaps between cells. Fixed dividers render as thick lines. Origin marker (accent triangle) in the label gutter corner outside cells. Labels reflect the version's labeling scheme and origin ordering. + +### Right Panel — Properties + +Properties are **always editable** — no edit/view mode toggle. Controls are always live form inputs. All changes immediately update the grid preview. + +When the current form state differs from the selected version's saved data, two buttons appear: +- **"Publish as v[N]"** — creates a new version with the current property values +- **"Revert"** — resets all controls back to the selected version's values + +When the form matches the selected version, neither button is shown. + +**"Set as Active"** button — appears when viewing a non-active version. Sets the selected version as the default for new inserts. + +**Property controls:** +- Dimensions — two number inputs (rows × cols) +- Row labels — radio: Alpha / Numeric +- Column labels — radio: Numeric / Alpha (validation: must differ from row labels) +- Origin — dropdown: Top-left, Top-right, Bottom-left, Bottom-right +- Row dividers — checkbox: Fixed (checked = permanent, blocks cross-row merging) +- Column dividers — checkbox: Fixed (checked = permanent, blocks within-row merging) +- Unit system — radio: Imperial / Metric + +### Version Application Flow + +Applying a version to instances (both upgrade and downgrade): + +1. User selects a version in the history table, then checks target instances +2. Click "Apply v[X] to Selected" → **preview panel** shows per-instance impact: + - **No conflicts** — structure is compatible, application is clean. Shows before/after grid side by side. + - **Override conflicts** — the instance has overrides (merges, divides) that conflict with the target version's structure. Lists each conflict with resolution options: keep override, drop override, or skip this instance. + - **Assignment conflicts** — locations that exist in the current version but not the target have active assignments. Lists affected assignments with options: reassign, unassign, or skip this instance. +3. User resolves conflicts per instance, or skips instances that need manual attention +4. Click "Apply" → instances updated, child locations restructured, toast with undo +5. Skipped instances remain on their current version — no partial changes per instance + +The application is a compound transaction — all changes for one instance are grouped and undone atomically. + +--- + +## Overrides (Grid-Interactive Operations) + +Overrides modify the structure of a placed insert or a fixed location. All three types are accessed from the grid view — select cells, then apply the operation. + +### Merge + +Combine adjacent cells into a single larger cell. + +1. User selects two or more adjacent cells in the grid (click first, shift-click additional) +2. Selected cells highlight with accent border +3. "Merge" action appears in a toolbar above the grid +4. Click merge → cells combine into a single region, labeled by the origin cell +5. Merged region renders as one `` spanning the combined area (or `` for non-rectangular shapes) +6. Toast: "Merged A3–A4" with undo + +**Constraints:** +- Cells must be adjacent and contiguous +- Template's **row dividers** and **column dividers** settings are enforced independently. If row dividers are fixed, the system blocks any merge that spans rows and explains why ("Row dividers on this template are permanent"). If column dividers are fixed, same for column-spanning merges. The merge action button is disabled for invalid selections. +- Existing assignments at affected cells must be migrated or removed first (enforced, not warned) + +### Divide + +Split a cell into named child positions. + +1. User selects a single cell in the grid +2. "Divide" action appears in the toolbar +3. Click divide → panel shows options: + - **Template-defined subdivision** — if the template defines subdivision options (e.g., "front/rear divider"), show those as named presets + - **Ad-hoc** — user specifies count and labels (e.g., 2 children: "Left", "Right") +4. Preview shows the cell with internal subdivision lines +5. Confirm → child locations created, parent cell becomes non-assignable +6. Toast: "Divided B2 into Left, Right" with undo + +**Constraints:** +- Existing assignment at the cell must be reassigned to a child or elsewhere before dividing (enforced) + +### Disable + +Mark a cell as unavailable. + +1. User selects a cell in the grid +2. "Disable" action appears in the toolbar +3. Click disable → optional reason prompt (e.g., "cracked divider") +4. Cell renders with diagonal stripe fill, reduced opacity +5. Toast: "Disabled C5: cracked divider" with undo + +Re-enable: select a disabled cell → "Enable" action appears → restores cell to active state. + +**Constraints:** +- Existing assignment must be reassigned or removed first (enforced) + +--- + +## Continuous-Dimension Locations + +Some storage defines capacity by physical dimensions rather than discrete grid positions. Louver panels and open shelves are the primary examples. + +### Template Configuration + +When creating a template for continuous-dimension storage, additional fields: +- **Dimension type** — "discrete" (default, grid-based) or "continuous" +- **Row width** — total available width per row (e.g., 36 inches) +- **Row pitch** — vertical spacing between rows (e.g., 3.5 inches) +- **Overflow direction** — "down" (hanging, like louver panels) or "up" (sitting, like shelves) + +### Visualization + +Continuous-dimension levels render differently from grids: +- Each row is a horizontal bar showing total width +- Placed inserts appear as blocks within the bar, sized proportionally to their consumed width (insert width + buffer) +- Remaining capacity shown as empty space +- Utilization percentage displayed per row +- Overflow indicators: if an insert's height exceeds row pitch, a visual indicator extends into the adjacent row + +### Placement + +Placing an insert into a continuous-dimension location: +1. System checks dimensional fit (insert width + buffer ≤ remaining width) +2. Insert appears in the row visualization +3. Ordering within a row is optional — inserts can be reordered or treated as unordered + +--- + +## SVG Grid Visualization + +Used in three contexts during storage definition: +1. **Module detail** — level preview (read-only) +2. **Place insert / apply template** — preview of what will be created +3. **Template detail** — canonical layout preview + +### Rendering Rules + +- SVG within a React component. Scales to fit available width, maintains aspect ratio. +- Rows labeled on left axis, columns labeled on top axis, per labeling scheme (alpha or numeric) +- Label ordering follows origin setting — labels always ascend outward from the origin corner (e.g., bottom-left origin → row A at bottom, column 1 at left) +- Each cell is a `` with label text centered (row label + column label, e.g., "A1") +- Cell border: `#475569` (slate-600) +- Cell fill: transparent (empty), `#1e3a5f` dark blue (occupied — has an assignment) +- Disabled cells: diagonal stripe pattern (`#f87171` at 40% opacity), reduced overall opacity +- Merged cells: single `` spanning combined area, labeled by origin cell (e.g., "A3–A4") +- Divided cells: parent cell split into sub-rects with custom labels (e.g., "L", "R") +- **Fixed dividers** — thick lines (`#94a3b8`, 3px, 80% opacity) between rows and/or columns where dividers are marked fixed. Rendered independently per axis. +- **Origin marker** — accent triangle (`#ff6600`) in the label gutter corner outside all cells, at the origin position. Not inside any cell. +- Hover: border thickens, shows cell label tooltip (DOM overlay) + +### Sizing + +- Cells are square by default +- Template detail context: 72px cells, 8px gaps, 14px axis labels, 12px cell labels +- Module detail / modal contexts: 52px cells, 2px gaps, 11px axis labels, 9px cell labels +- Grid scales down for large templates (many columns), minimum cell size 24px +- Horizontal scroll if the grid exceeds available width at minimum cell size + +--- + +## Empty States + +| Context | Message | Action | +|---|---|---| +| Module list, no modules | "No modules yet. Create your first storage module to start organizing." | "New Module" button | +| Module detail, no levels | Should not happen — levels are auto-generated | — | +| Level selected, empty receptacle | "No insert placed." | "Place Insert" button | +| Level selected, empty fixed | "No structure defined." | "Apply Template" button | +| Template list, no templates | "No templates defined. Create a template to define reusable storage layouts." | "New Template" button | +| Place insert, no templates exist | "No templates available. Create a template first." | Link to /templates/new | + +--- + +## Data Flow + +### Module creation +1. POST `/api/modules` → creates module +2. POST `/api/locations` × N → creates one location per level (children of module) +3. Each location: `moduleId`, `label`, `path` (e.g., "MUSE:3"), `locationType: "receptacle"` + +### Place insert +1. POST `/api/inserts` → creates insert record (references template + version) +2. POST `/api/inserts/:id/place` → places insert at receptacle location (validates compatibility) +3. System generates child locations from template version's position definitions +4. GET `/api/locations?moduleId=X` → refresh level table and grid preview + +### Apply template to fixed location +1. Locations created directly as children of the fixed location, referencing the template version +2. No insert record — the structure is permanent + +### Template creation +1. POST `/api/templates` → creates template with version 1 +2. Subsequent versions: POST `/api/templates/:id/versions` + +--- + +## Resolved Questions + +1. **Level reordering** — deferred. No clear use case yet. +2. **Module photos** — deferred. Metadata field supports it; upload UI comes later. +3. **Template sharing** — no import/export. Multi-tenant: templates promoted to system level via referential links, not per-tenant copies. +4. **Undo** — always implemented. Toast notification with undo button on every mutation. Undo via transaction log per ui-paradigms.md. Only omit undo when explicitly agreed. +5. **Custom row/column labels** — deferred. Current options are alpha and numeric. Custom labels (e.g., color names like "black, blue, red, green") are a future feature. +6. **Template version list length** — versions with 0 instances can be hidden from the list to prevent clutter. Data is retained in the database. No pagination needed if unused versions are pruned. + diff --git a/specification/storage-model.md b/specification/storage-model.md new file mode 100644 index 0000000..12baf83 --- /dev/null +++ b/specification/storage-model.md @@ -0,0 +1,478 @@ +# Storage Model Specification + +This document defines the foundational data model for WhereTF's storage system. It was developed through interactive design sessions and represents the agreed-upon concepts, terminology, and relationships. + +## Global Points +- All names, labels, and descriptions are UTF-8 and unrestricted. +- The system imposes no character restrictions on user-supplied text. +- Display formatting and internal representation are separate concerns. + +--- + +## Glossary + +### Module +A top-level, independent physical storage unit. Has a user-chosen name, a description, and a single primary dimension. Modules are never nested inside other modules. Spatial relationships between modules (e.g., "NEON lives under the workbench") are descriptive metadata, not structural. + +A module's primary dimension defines its top-level locations (e.g., 11 levels, 16 drawers, 3 named sections). Each of those locations is either a **receptacle** or has **fixed** sub-structure (see Location Types below). + +Examples: a red cabinet (MUSE), an IKEA ALEX drawer unit, a shelving unit. + +### Template +An abstract blueprint representing a real storage product, including a user's custom design. Defines positions, their arrangement, and physical constraints. Templates are never modified by instance data — they remain pristine reference definitions. + +A template defines: +- **Unit system (display)** — metric or imperial. Declares how dimensions are presented and entered in the UI. Canonical storage is always millimeters (SI). The UI converts on read/write (e.g., an imperial template displays "1 in" for a stored value of 25.4). +- **Origin** — which position is the reference point +- **Primary axis** — orientation for insert compatibility and labeling direction +- **Labeling scheme** — how positions are named (numeric, alpha, row-col, custom) +- **Positions** — the arrangement and count (for discrete-position templates) or the physical dimensions of the location (for continuous-dimension templates) +- **Subdivision options** — ways positions can be divided, including the labels for resulting child positions +- **Physical constraints** — soft limits (warn) and hard limits (block) on dimensions or other physical properties (e.g. powders, liquids, gases, spools, rolls) +- **Interface types accepted** — what inserts this template's positions can receive (if any) +- **Interface types provided** — what receptacle types this template fits into (for insert templates). An insert template may provide multiple interface types (e.g., an Akro bin provides both a louver-hang interface and an open-surface interface). + +Templates carry a fixed structural core plus extensible metadata with no prescribed shape. Photos, manufacturer details, product numbers, physical dimensions, weight capacity, material — whatever is useful. + +Every location resolves its dimensions through a template version. Templates have a **scope**: +- **Shared** (default) — represents a real product or reusable user design. Appears in template pickers. +- **Single-instance** — auto-created to back an ad-hoc location (e.g., a custom shelf the user defines once). Hidden from pickers. This lets the system have one capacity-resolution path without a separate ad-hoc code path. + +There are three kinds of templates: +- **Fixed templates** — represent a specific product with a fixed layout. A Plano 3600 Stowaway is always 4 rows × 6 columns. An Akro-Mils 30220 AkroBin is a fixed single-compartment bin with known dimensions. +- **Parametric templates** — represent a system with a standard unit, instantiated at user-specified dimensions. A Gridfinity baseplate defines the 42mm grid unit; the user specifies N×M at instantiation. Parametric templates define their unit, constraints (min/max grid size), and labeling scheme. The user supplies dimensions when applying the template. +- **Continuous-dimension templates** — define locations by physical dimensions rather than discrete positions. A louver panel row has a width; inserts placed in it consume that width. See Continuous-Dimension Locations below. + +Insert templates may declare a **buffer** — a flat clearance value added to the insert's primary dimension when computing fit within a continuous-dimension location. For example, a 2" wide bin with a ¼" buffer effectively consumes 2.25" of row width. Buffer is a property of the insert's form factor, not the location. + +#### Template Versioning + +Templates are versioned. Each version is immutable once published. When a template is applied to an insert or a fixed location, the instance records which version it was applied from. + +- Editing a template publishes a new version. Existing instances stay on their applied version. +- Updating deployed instances to a newer version is an explicit operation — preview structural changes, resolve conflicts (broken overrides, displaced assignments), then apply. +- Version history is append-only. Old versions are never deleted, even when no instances reference them. They remain accessible for undo, audit, and instantiating older product models. +- The UI presents a template as a single entity with a current version. Version history is a detail view, not a primary interaction. + +Examples: Plano 3600 Stowaway (fixed), Gridfinity baseplate (parametric), Akro-Mils 10116 16-drawer cabinet (fixed). + +### Position +An abstract place defined within a template. Has no path, holds no items. A position is a blueprint concept only. When a template is applied to a module, each position becomes a location. + +### Location +A concrete, addressable place within a module instance. Has a path. Created when a template is applied to a module location, when a location is divided, or when a module's primary dimension is defined. + +A location is either: +- A **leaf** — can hold item assignments +- A **parent** — has child locations, cannot directly hold item assignments + +There is no special term for a parent location. It is simply a location with children. + +#### Location Types + +Every location that can have sub-structure is one of two types: + +**Receptacle** — an empty location that accepts inserts. It declares an interface type (e.g., "plano-3600", "gridfinity-42mm"). Sub-locations are created by whatever insert occupies it. The insert is movable — it can be removed, replaced, or relocated to another compatible receptacle. + +Example: a MUSE shelf level is a receptacle that accepts Plano-compatible inserts. A Plano box can be relocated to any other compatible receptacle, or replaced with any insert that provides the same interface type. + +**Fixed** — sub-structure is defined directly on the module or by a template applied permanently. The structure is built-in and not relocatable. + +Example: an ALEX drawer unit's 9 drawers are fixed — they are part of the furniture. A Gridfinity baseplate layout inside an ALEX drawer is also fixed — once configured, the baseplate grid is structural. + +A location is one or the other. A receptacle's sub-structure comes from its insert. A fixed location's sub-structure comes from the module's own configuration or a permanently applied template. + +#### Continuous-Dimension Locations + +A location may define capacity by physical dimensions rather than discrete positions. Instead of containing N positions, it has a measurable width (and optionally height and depth). Inserts placed in the location consume space along those dimensions. The system tracks utilization: the sum of (insert dimension + buffer) for all placed inserts, compared against the location's capacity. + +- **Dimensional utilization** — the system computes total consumed width vs. available width. Soft limit warns when nearing capacity; hard limit blocks placement when an insert would exceed capacity. +- **Ordering** — inserts within a continuous-dimension location may optionally be ordered (e.g., left-to-right). Ordering is not enforced — the location may be treated as an unordered set if spatial sequence is not meaningful. + +Examples: a louver panel row (bins consume width), an open shelf (bins consume width and must fit within shelf height). + +#### Overflow Direction + +A location may declare an **overflow direction** — the direction in which an oversized insert extends into adjacent locations: + +- **Down** — the insert hangs from the location, and excess height extends into the row/level below. Used for louver rails and hanging storage where bins are suspended from a rail. +- **Up** — the insert sits on the location, and excess height extends into the row/level above. Used for shelves where tall items stand upward. + +Overflow direction is a property of the location, not the insert. The same bin template may be placed in a hanging location (overflow down) or a shelf location (overflow up). When an insert's height exceeds the location's row pitch, the system checks for clearance conflicts in the overflow direction. + +### Insert +A distinct physical object that occupies one or more receptacle locations and provides its own internal locations. Each insert is an individual instance — if you own 8 Plano boxes, each is a separate insert record, potentially with different overrides. + +Key properties: +- **Relocatable as a unit** — moving an insert carries all its internal structure, overrides, and assignments to the new receptacle location +- **Overrides live on the insert** — structural modifications (merged cells, divided compartments, disabled positions) describe the physical state of this specific object, not the receptacle it sits in +- **Footprint** — how many receptacle locations the insert occupies (for discrete-position locations, e.g., a Gridfinity 2×1 bin spans two baseplate positions) or the physical dimensions consumed (for continuous-dimension locations, e.g., a 4⅛" wide bin consumes 4⅛" + buffer of row width) +- **Buffer** — a flat clearance value declared on the insert's template, added to the insert's dimension when computing fit within a continuous-dimension location +- **Must respect origin and primary axis alignment** of the receptacle + +An insert can be configured: +- **Template-based** — references a template, as-is +- **Template with overrides** — references a template, with structural modifications layered on top +- **Structurally defined** — custom internal layout, no template reference + +An insert can exist unassigned — a new Plano box not yet placed in any module. + +Compatibility between inserts and receptacles is governed by interface types (see below). Placement is rejected unless the insert provides an interface type the receptacle accepts. + +Examples: a Plano box on a MUSE shelf level, a Gridfinity bin on a baseplate. + +### Interface Type +A named physical contract that governs compatibility between inserts and receptacles. Defines the form factor boundary — what fits into what. + +An interface type specifies: +- **Identifier** — a unique name (e.g., "plano-3600", "gridfinity-42mm") +- **Physical contract** — the dimensional and mounting constraints the name represents (footprint, attachment mechanism, clearance requirements) +- **Directionality** — a template either *provides* an interface type (insert side: "I fit into plano-3600 receptacles") or *accepts* one (receptacle side: "I accept plano-3600 inserts"), never both on the same boundary + +Compatibility rules: +- Placement of an insert into a receptacle is **strictly validated** — the insert must provide an interface type that the receptacle accepts. No implicit compatibility. For continuous-dimension locations, dimensional fit is also checked. +- An insert template can provide **multiple interface types** (e.g., an Akro-Mils bin provides both a louver-hang interface and an open-surface interface, because it can hang on a rail or sit on a shelf). +- Multiple insert templates can share the same interface type (e.g., several third-party organizers that all fit the Plano 3600 form factor). +- A receptacle can accept multiple interface types if physically compatible. +- The taxonomy of interface types is intentionally open and will evolve as real storage products are modeled. Interface types are system-defined, not user-created. Users select from known types when configuring templates. + +### Item +What a thing is, independent of where it is. Has a name, description, and parameters (key/value/unit triples, images). Represents a type or category, not an individual instance or count. + +Items can exist at multiple locations via multiple assignments. An item at two locations is one item definition with two assignments — referential, not duplicative. + +Item definitions must be unambiguous within the user's collection. Adding a similar item may require refining an existing item's definition (adding parameters, sharpening the name) to maintain distinguishability. Definitions become progressively more detailed as the collection grows. + +Equivalent to a product in ERP systems (e.g., Odoo). Future integration with ERP systems is a design guardrail. The deep detail of item management (supplier info, datasheets, equivalents) should be abstracted to a separate concern — an ERP system or a simpler standalone tool for home workshop users. + +Examples: "10k 0805 resistor", "M3x10 socket head cap screw", "CA glue", "3D printer filament", "14 AWG stranded red wire". + +### Assignment +The relationship between an item and a location. "This item is assigned to this location." + +An assignment is either **placed** or **provisional**: + +**Placed** — the item occupies a specific leaf location. Subject to the one-per-location rule (with co-storability exceptions). This is the normal, organized state. + +**Provisional** — the item is *at* a location but not *in* a specific position. The item is physically present somewhere within that location's scope, but the user hasn't specified (or doesn't care about) the exact position. Valid at any level in the hierarchy, including parent locations. + +Example: "Resistors are on MUSE 3" — the user set them on the shelf level but hasn't sorted them into a grid cell yet. The system answers "where are my resistors?" with "MUSE 3, unplaced." Later refinement to `MUSE 3 / B4` converts the provisional assignment to a placed one. + +Provisional assignments: +- Are queryable and appear in search results, clearly marked as unplaced +- Do not occupy a position — they don't block placed assignments in child locations +- Create organizational pressure but don't force immediate resolution +- Are not subject to the one-per-location rule (a parent can have multiple provisional items) + +Multiple placed assignments per location are allowed when items are co-storable — related items that are practical to store together and separated at time of use. This avoids expanding storage capacity unnecessarily for items whose differences are easily discerned by hand. + +Example: M3x10 SHCS in black oxide and bright zinc in one bin (finish is obvious at a glance). Maintaining separate locations for every permutation of drive, length, thread, head, and finish becomes impractical. + +Co-storability is an item-level relationship. Items declare which other items they can share a location with. The system must surface co-stored items clearly so the user knows a location contains multiple items. + +### Subdivision +Any location can be subdivided into named child locations. There are two forms: + +**Template-defined** — a subdivision option on a template, often corresponding to a physical accessory. The option defines the labels for the resulting child locations. Example: the Akro-Mils 40716 divider splits a drawer into "front" and "rear" — those labels are part of the subdivision option, not user-supplied. + +**Ad-hoc** — the user splits a location informally, specifying the number of children and their labels. No template or accessory required. Example: a piece of cardboard divides a bin into "left" and "right." + +In both cases, the original location becomes a parent and is no longer a valid assignment target. See the Divide override for prerequisites. + +--- + +## Override Types + +Overrides are structural modifications or capacity clamps that deviate from a template's default layout. There are four types: Merge, Divide, Disable, and Restrict. + +Overrides can apply to: +- **An insert** — describes the physical state of that specific object. Moves with the insert when relocated. (e.g., "this Plano box has cells 3 and 4 merged") +- **A module location** — describes the physical state of the module itself. Stays with the module. (e.g., "this shelf slot is damaged") + +### Merge +Combine two or more adjacent locations into a single location. + +- Target locations must be adjacent and form a contiguous region (not necessarily rectangular — L-shapes and other contiguous arrangements are valid) +- Templates may constrain which axes allow merging. Example: a Plano 3600's rows are molded walls — columns can be merged within a row, but rows cannot be merged across. +- The merged location's path uses the position closest to the template-defined origin +- Non-origin locations become **aliases** that redirect to the origin location (e.g., querying col-4 returns col-3's contents when merged with col-3) +- Prerequisite: assignments at affected locations must be migrated or removed before merging. Strictly enforced. + +### Divide +Split a single location into child locations. + +- Can apply a subdivision option defined on the template +- Can apply an insert's template +- Can define a custom ad-hoc split (user specifies number of children and their labels) +- The subdivision option, insert template, or user input defines the labels for the resulting child locations +- The original location becomes a parent and is no longer a valid assignment target +- Prerequisite: existing assignment must be reassigned to a child location, reassigned elsewhere, or unassigned. The operation cannot complete with unresolved assignments. Strictly enforced. + +### Disable +Mark a location as unavailable for assignment. + +- Reason is optional (descriptive text: "cracked divider", "reserved for tool") +- The location still exists in the structure but cannot hold assignments +- Reversible — enable restores availability +- Prerequisite: existing assignment must be reassigned or unassigned. Strictly enforced. + +### Restrict +Clamp a location's usable capacity below its template's nominal capacity. Does not change structure — reduces the usable envelope. + +- Applies independently to width, height, or depth (any subset) +- Reason is optional (descriptive text: "must slide under shelf above", "finger groove at front") +- Effective dimension at placement time is `min(template.dim, location.maxDim)` for each axis +- Examples: a drawer whose nominal height is 80 mm but only 60 mm of that is clear under an obstruction; a shelf cell that must leave 15 mm at the front for finger access +- Reversible — clearing the clamp restores full capacity +- Prerequisite: assignments that no longer fit must be resolved before the clamp is applied. Strictly enforced. + +--- + +## Path Structure + +### Internal Representation (immutable convention) + +Paths have three layers. The internal and serialized forms are fixed and must never change. + +**Source of truth:** ordered array of segments. No delimiter, no encoding issues. +``` +["MUSE", "3", "B4"] +["ALEX", "4", "B2", "Front"] +``` + +**Serialized form:** colon-delimited string for storage, indexing, and prefix queries. Colons have near-zero collision with workshop nomenclature (part numbers, dimensions, fastener specs). Segments must not contain colons. +``` +MUSE:3:B4 +ALEX:4:B2:Front +``` + +### Display Formats (flexible, may evolve) + +User-facing display is a separate concern from internal representation. + +**Brief** — compact, optimized for scanning and speech: +``` +MUSE 3 / B4 +ALEX 4 / B2 / Front +``` + +**Verbose** — explicit dimension labels for clarity: +``` +MUSE 3 / Row B, Col 4 +ALEX Drawer 4 / Row B, Col 2 / Front +``` + +Display formatting rules: +- Module name + primary dimension value are space-separated: `MUSE 3` +- Sub-dimension boundaries use slash: `/` +- Grid cells use row-letter + column-number notation: `B4` (row B, column 4) +- Dimension labels (Row, Col, Drawer) come from the template's labeling scheme +- The number immediately following a module name always indicates the primary dimension value +- Default labeling convention: rows are alpha (A, B, C, ...) top-to-back, columns are numeric (1, 2, 3, ...) left-to-right. Origin is top/back, left side. + +### Spoken Form +Paths are designed to be naturally speakable: "Muse 3, B-4" or "Alex 4, B-2, front." No delimiters are verbalized. + +### Path Behavior Under Overrides + +**Merge:** Non-origin paths become aliases redirecting to the merged location's origin path. Querying an alias returns the origin location's data. + +**Divide:** The divided location's path is no longer valid for assignment. Child location paths extend the parent path with labels from the subdivision option or insert template. + +**Disable:** Path remains valid and addressable but the location is marked unavailable for assignment. + +--- + +## How Templates Create Locations + +Templates create child locations in two distinct ways, matching the two location types: + +**Via insert (receptacle locations)** — placing an insert into a receptacle creates child locations defined by the insert's template. The insert is a physical instance; the template defines its structure. Example: placing a Plano 3600 insert into MUSE level 3 creates a 4×6 grid of child locations under that level. + +**Via direct application (fixed locations)** — applying a template permanently to a module location creates built-in child locations. Example: applying a Gridfinity baseplate template to ALEX drawer 3 with dimensions 6×4 creates a fixed 6×4 grid. + +For parametric templates, the user supplies dimensions at application time. + +### Template Limits +Templates define physical constraints on their dimensions: +- **Soft limits** — warn the user ("This exceeds the Plano 3600's standard layout — are you using a modified insert?") +- **Hard limits** — block the configuration ("A Plano 3600 physically cannot have more than 6 columns") + +### Changing Structure +Removing an insert from a receptacle removes its child locations. Replacing a fixed template on a module location is a destructive reconfiguration. In both cases, existing assignments under the affected location must be resolved first. + +--- + +## Concrete Examples + +### MUSE (Red Cabinet with Shelf Levels) + +MUSE is a cabinet with 11 levels. Each level is a **receptacle** that accepts Plano-compatible inserts. + +``` +Module: MUSE + Primary dimension: level (1-11) + + Location: level 1 ← receptacle (accepts: plano-3600) + Insert: plano-box-001 ← a specific Plano 3600 instance + Location: A1 ← leaf, can hold an assignment + Location: A2 + ... + Location: D6 + + Location: level 4 ← receptacle + Insert: plano-box-004 ← another Plano 3600 instance which happens to have overrides + Location: A1 + Location: A2 + Location: A3+A4 ← merged (override on this insert) + Location: A5 + Location: A6 + ... + + Location: level 10 ← receptacle, currently no insert + ← leaf by default, can hold an assignment directly + ("Construction Screws are on level 10") +``` + +Moving plano-box-004 from level 4 to level 10: the insert, its overrides, and all its assignments relocate as a unit. Level 4 becomes an empty receptacle. Level 10's direct assignment (if any) must be resolved first. + +### ALEX (IKEA Drawer Unit with Gridfinity) + +ALEX has 9 drawers. Drawers are **fixed** — they are part of the furniture. Inside some drawers, a Gridfinity baseplate layout is configured, which becomes fixed for our purposes. Gridfinity bins sit on the baseplate as **inserts**. + +``` +Module: ALEX + Primary dimension: drawer (1-9) + + Location: drawer 3 ← fixed (module-defined) + Location: A1 ← fixed (GF baseplate grid, parametric 6×4) + Insert: gf-2x1-3comp-017 ← a specific GF 2×1 bin with 3 compartments + Location: comp 1 ← leaf + Location: comp 2 ← leaf + Location: comp 3 ← leaf + Location: A3 ← next baseplate position (bin spans 2 cols) + Insert: gf-1x1-bin-042 ← a specific GF 1×1 bin, single compartment + Location: (single cell) ← leaf + ... + + Location: drawer 7 ← fixed, no baseplate configured + ← leaf, can hold an assignment directly +``` + +### AKRO (Akro-Mils Small Parts Cabinet) + +AKRO has 16 drawers in a fixed layout. Some drawers have been divided using divider accessories. + +``` +Module: AKRO + Primary dimension: drawer (1-16) + Template: Akro-Mils 10116 (fixed, defines 16 drawer positions) + + Location: drawer 1 ← fixed (module-defined) + ← leaf, single undivided compartment + + Location: drawer 7 ← fixed + Override: divide (using 40716 divider → front + rear) + Location: front ← leaf + Location: rear ← leaf + + Location: drawer 12 ← fixed + Override: disable (reason: "cracked drawer, on order") +``` + +### LOUVER (Akro-Mils Louvered Panel with Hanging Bins) + +LOUVER is a wall-mounted louvered panel. It has rows defined by the louver rail spacing. Each row is a **continuous-dimension location** — bins consume width, not discrete positions. Bins hang from the rail, so overflow direction is **down** (a tall bin extends into the row below). + +``` +Module: LOUVER + Primary dimension: row (1-8) + Template: Akro-Mils 30636 (continuous-dimension, imperial) + Row width: 36 in + Row pitch: 3.5 in (vertical spacing between rails) + Overflow direction: down + + Location: row 1 ← continuous-dimension (width: 36 in, overflow: down) + Insert: akro-30220-001 ← 4⅛" wide bin, ¼" buffer → consumes 4.375" + Location: (single cell) ← leaf + Insert: akro-30220-002 ← another 4⅛" bin → consumes 4.375" + Location: (single cell) ← leaf + Insert: akro-30230-005 ← 5½" wide bin → consumes 5.75" + Override: divide (ad-hoc → left + right) + Location: left ← leaf + Location: right ← leaf + ... ← total consumed: 14.5" of 36" available + + Location: row 4 ← continuous-dimension + Insert: akro-30250-010 ← 10⅞" wide, 7" tall (spans 2 row pitches) + Location: (single cell) ← leaf, overflow extends into row 5 + ... + + Location: row 7 ← continuous-dimension, currently empty + ← leaf, can hold a provisional assignment +``` + +The same Akro-Mils bin templates can also be placed on a shelf (overflow direction: up) without any change to the bin template — the bin provides multiple interface types (louver-hang and open-surface). + +### SHELF (Open Shelving Unit) + +SHELF is a utility shelving unit. Each level is a **continuous-dimension location** — bins and items consume width. Items sit on the shelf, so overflow direction is **up**. + +``` +Module: SHELF + Primary dimension: level (1-5) + Template: custom open shelf (continuous-dimension, imperial) + Level width: 48 in + Level height: 15 in + Overflow direction: up + + Location: level 2 ← continuous-dimension (width: 48 in, overflow: up) + Insert: akro-30220-015 ← same bin type as LOUVER, sitting on shelf + Location: (single cell) ← leaf + Insert: plano-box-012 ← Plano box sitting on the shelf (open-surface interface) + Location: A1 ← discrete grid inside the Plano box + ... +``` + +--- + +## Established Points with Rationales + +### Modules are always top-level +Modules do not nest. This keeps the model simple and avoids recursive module resolution. Physical containment relationships (a drawer unit inside a workbench) are captured as metadata today. A future construct may formalize inter-module relationships without introducing nesting. + +### Items are independent of locations +Items and locations are distinct entities connected by assignments. This separation supports multiple assignments per item, future ERP integration, and clean relocation semantics. + +### One assignment per location, with co-storability +The default is one item per location. Multiple assignments are allowed only when items are co-storable — related items that are practical to store together and separated at time of use. Co-storability is an item-level relationship, not a location property. The system must surface co-stored items clearly so the user knows a location contains multiple items. + +### No item compatibility constraints (deferred) +Storage medium types (binned, racked, bulk) and item/location compatibility validation are recognized as valuable but deferred. The model can accommodate these as template and item metadata when needed. + +### No quantity tracking +The system answers "where is it?" not "how many?" An item represents a category, not a count. Inventory tracking, including counts, is the domain of ERP (e.g., Odoo-based Charm). + +### Progressive organization via provisional assignments +Items can be provisionally assigned at any level in the hierarchy, including parent locations. Assigning to `MUSE 3` without specifying a grid position creates a provisional assignment — the item is at that level, position undetermined. Refining to `MUSE 3 / B4` converts it to a placed assignment. This supports the real-world workflow of setting items down now and organizing later, without violating the structural rules for placed assignments. + +### Compatibility via interface types +Insert/receptacle compatibility is strictly enforced through named interface types. A template declares what interface type it provides (insert side) and/or accepts (receptacle side). Placement is rejected on mismatch. Multiple insert types can share an interface type. Interface types are system-defined, not user-created. + +### Templates are pristine and versioned +Templates are never modified by instance data. Overrides live on inserts or module locations. Templates are versioned — editing publishes a new version; existing instances stay on their applied version. Updating instances to a newer version is an explicit operation with conflict resolution. Version history is append-only. + +### No linking between levels +Each module location independently references its template. Batch operations ("apply this template to levels 2-5") are a UI/API convenience, not a data model concept. This keeps the data model simple. + +### Discrete vs. continuous-dimension locations +Not all storage fits a grid. Louver panels, open shelves, and similar storage define locations by physical dimensions. Inserts consume measurable space rather than occupying discrete positions. Both modes coexist in the same model — a module can have discrete-position locations (Gridfinity baseplates) and continuous-dimension locations (open shelves) in different parts of its structure. + +### Unit system is per-template (display only) +Different storage products use different measurement systems. An Akro-Mils panel is natively imperial; a European shelving system is metric. The template declares its display unit system; all dimensions are canonically stored in millimeters. The UI converts on entry and presentation. A single canonical unit avoids mixed-unit aggregation bugs when computing utilization across heterogeneous locations. + +### Overflow direction is a location property +The same bin can hang on a louver rail (overflow down) or sit on a shelf (overflow up). The physical context — the location — determines which direction excess height extends, not the insert. + +### Interface type taxonomy is open +The set of interface types will evolve as real storage products are modeled. The model defines the compatibility mechanism (provide/accept) but intentionally leaves the taxonomy open. Over-specifying interface types before real-world usage would create artificial constraints. diff --git a/specification/storage-navigator-design.md b/specification/storage-navigator-design.md new file mode 100644 index 0000000..8c391cd --- /dev/null +++ b/specification/storage-navigator-design.md @@ -0,0 +1,365 @@ +# Storage Navigator — UI/UX Specification + +## Overview + +GUI-first visual interface for modeling, browsing, and managing physical storage. The grid is the primary interaction surface — not a secondary view driven by chat or search. Every mutation is logged to a transaction log, enabling undo of any action. + +--- + +## Layout + +Three-pane adaptive layout with collapsible sidebar. + +``` +┌────┬──────────────────────────┬──────────────────────────┐ +│ │ │ │ +│ S │ Module Explorer │ Storage Grid / Detail │ +│ I │ │ │ +│ D │ Card list, drill-down │ SVG grid, detail panel │ +│ E │ hierarchy, search │ insert mgmt, actions │ +│ B │ │ │ +│ A │ │ │ +│ R │ │ │ +└────┴──────────────────────────┴──────────────────────────┘ +``` + +**Sidebar** — icon rail when collapsed, expands to labeled nav. Links: Dashboard, Modules, Items, Search, Templates, Activity (transaction log). Collapse state persists. + +**Center pane (Module Explorer)** — module card list → drill into levels → drill into positions. Breadcrumb navigation at top: `Modules / MUSE / Level 3`. Search bar at top of module list. + +**Right pane (Storage Grid / Detail)** — appears when a location with sub-structure is selected. Shows SVG grid for grid-based locations, or detail panel for leaf locations. Contextual actions in a toolbar above the grid. + +### Responsive Behavior + +- **Desktop (≥1200px)** — all three panes visible, resizable +- **Tablet (768–1199px)** — sidebar collapses to icon rail, two panes visible +- **Mobile (<768px)** — single pane with navigation stack, swipe or back-button to return + +--- + +## Module Explorer (Center Pane) + +### Module List + +Card grid. Each card shows: +- Module name (prominent) +- Description (truncated) +- Primary dimension summary (e.g., "11 levels", "9 drawers") +- Occupancy indicator — simple fill bar, not a percentage +- Quick stats: total locations, assigned locations + +Cards are filterable by text search and sortable (name, occupancy, recent activity). + +Empty state: "No modules configured. Create your first module to start organizing." + +### Drill-Down + +Click a module card → level/drawer list replaces the card grid. Breadcrumb updates. + +Each level/drawer row shows: +- Dimension value and optional name +- Location type badge: `receptacle` or `fixed` +- Insert name (if occupied receptacle) +- Occupancy indicator +- Provisional assignment count (if any, shown as a distinct badge) + +Click a level/drawer → right pane opens with SVG grid (if sub-structure exists) or detail view (if leaf). + +### Breadcrumb + +Path segments are clickable for navigation back up the hierarchy. Current segment is not a link. Format follows the display brief format: `MUSE / Level 3 / B4`. + +--- + +## Storage Grid (Right Pane) + +SVG-rendered grid of positions within a level, drawer, or insert. This is the primary visualization surface. + +### Grid Structure + +- Rows labeled alphabetically (A, B, C, ...) top-to-back on left axis +- Columns labeled numerically (1, 2, 3, ...) left-to-right on top axis +- Origin: top-left (A1) +- Labels come from the template's labeling scheme when available + +### Cell Visual Vocabulary + +Each cell is an SVG group (``) containing layered visual elements that encode state at a glance: + +**Border shape** — outer cell boundary +- Rectangle (default) — standard position +- Rounded rectangle — TBD category mapping +- Additional shapes reserved for future category encoding + +**Border color** — encodes primary category or status +- Default border (neutral gray) — empty or uncategorized +- Category-mapped color — item's primary category determines border hue +- Orange accent (#ff6600) — active selection or action target +- Red — disabled position + +**Fill** — encodes occupancy and assignment type +- No fill (transparent) — empty, available +- Solid light fill — occupied (placed assignment) +- Hatched or dotted fill — provisional assignment (item is here, position undetermined) +- Striped fill (diagonal) — disabled (with reason on hover) + +**Inner content** — encodes item identity within the cell +- Glyph or icon — categorical visual shorthand (e.g., a resistor symbol, a screw silhouette) +- Text label — item name, truncated to fit +- Co-storability indicator — split cell or stacked indicator when multiple items share the location +- Search result badge — numbered overlay matching search results + +**Merged cells** — rendered as a single SVG region spanning the merged positions. Non-rectangular merges (L-shapes) render as a compound path. The merged region uses the origin position's label. + +**Divided cells** — rendered with internal subdivision lines. Child positions labeled per the subdivision scheme. Each child is independently interactive. + +### Cell Interactions + +**Hover** — tooltip appears adjacent to the cell (DOM overlay, not SVG). Tooltip shows: +- Position label (e.g., B4) +- Item name (if assigned) +- Assignment type (placed / provisional) +- Co-stored items (if any) +- Override info (merged from, divided into, disabled reason) + +**Click** — opens detail panel below or beside the grid (within the right pane). Detail panel shows: +- Full item information (name, description, parameters) +- Assignment details (placed/provisional, date assigned) +- Co-stored items list +- Override history +- Actions: reassign, unassign, move, edit override + +**Drag** (future) — drag an insert to relocate it. Drag an item to reassign. Visual feedback shows compatible drop targets (interface type validation). + +### Grid Toolbar + +Above the grid, contextual actions: +- **Zoom controls** — fit to pane, zoom in/out +- **View toggle** — grid view / list view of positions +- **Filter** — show only occupied, only empty, only provisional +- **Actions menu** — add insert, apply template, override operations + +--- + +## Search Integration + +Search lives in the module explorer's header. Two modes: + +**Basic search** — keyword match against item names, descriptions, parameters. Instant results as you type. + +**AI search** (when available) — natural language queries. Deferred feature, but the UI slot exists from day one. + +### Search Results in Grid + +When search returns results: +1. Results list appears in the center pane, replacing or overlaying the current view +2. Each result shows: item name, location path, match context +3. Clicking a result navigates the explorer to that module/level and opens the grid +4. The result's cell gets a numbered badge overlay (1, 2, 3...) with a distinct color per result +5. If multiple results are in the same grid, all badges appear simultaneously +6. Non-result occupied cells remain visible but visually recede (reduced opacity) + +Results are grouped by module/level for efficient scanning. + +--- + +## Insert Management + +### Placing an Insert + +1. User selects "Add insert" from grid toolbar on a receptacle location +2. System shows compatible templates (filtered by interface type) +3. User selects a template → preview appears in the grid showing the insert's positions as ghost cells +4. User confirms → insert is created, positions become locations, transaction is logged +5. Notification: "Plano 3600 placed in MUSE Level 3" with undo option + +### Relocating an Insert + +1. User selects an insert (via grid toolbar or detail panel) +2. "Relocate" action shows compatible receptacles across all modules (filtered by interface type) +3. User selects destination → preview shows the insert in the new location +4. User confirms → insert moves with all overrides and assignments, transaction is logged +5. Notification: "Plano 3600 moved from MUSE Level 3 to MUSE Level 7" with undo option + +### Interface Type Validation + +Incompatible actions are prevented, not just warned: +- Placing an insert into a receptacle that doesn't accept its interface type → action is blocked, message explains why +- Compatible receptacles are visually indicated when placing/relocating (green highlight on valid targets, no highlight on invalid) + +--- + +## Assignment UX + +### Placing an Item + +1. User clicks an empty cell → detail panel opens with "Assign item" action +2. Item picker: search/browse items, select one +3. Assignment is created immediately → cell updates with item visual encoding +4. Notification: "M3x10 SHCS assigned to MUSE 3 / B4" with undo option + +### Provisional Assignment + +1. User assigns an item to a parent location (e.g., a level, not a specific cell) +2. Provisional badge appears on the parent in the explorer drill-down view +3. In the grid, provisional items appear in a banner above or below the grid (not in a cell, since they have no position) +4. "Place" action on a provisional item: click a cell to convert it to a placed assignment + +### Co-Stored Items + +When items share a location: +- Cell shows a split or stacked visual (two colors, divided diagonally or stacked) +- Tooltip lists all items +- Detail panel shows all items with their co-storability relationship +- Adding a non-co-storable item to an occupied cell → action is blocked with explanation + +--- + +## Transaction Log + +Every mutation is recorded as an immutable transaction entry. This is foundational infrastructure, not an afterthought. + +### What Gets Logged + +Every state change to the storage model: +- Assignment: create, move, convert (provisional→placed), remove +- Insert: place, relocate, remove +- Override: merge, divide, disable, revert +- Module: create, edit, delete +- Template: create, new version, apply, update instance +- Location: create, remove (cascading from insert/template operations) + +### Transaction Entry Structure + +Each entry records: +- **Timestamp** +- **Actor** — user who performed the action +- **Action type** — the operation performed +- **Entity** — what was affected (item, assignment, insert, location, module, template) +- **Before state** — snapshot of affected data before the change +- **After state** — snapshot of affected data after the change +- **Related transactions** — parent transaction ID for compound operations (e.g., relocating an insert creates sub-transactions for each assignment move) +- **Undo status** — whether this transaction has been undone, and by which transaction + +### Compound Transactions + +Some user actions produce multiple state changes. These are grouped under a single parent transaction: +- Relocating an insert → move insert + move all assignments + update all paths +- Merging cells → merge override + migrate/remove affected assignments +- Removing an insert → remove insert + remove all child locations + unassign all items + +Undoing a compound transaction unwinds all sub-transactions atomically. + +### Undo Mechanics + +- Any transaction can be undone if its affected entities haven't been further modified +- If a conflicting change has occurred since the transaction, undo is blocked with an explanation of the conflict +- Undo itself is a transaction (logged, and can be re-done) +- There is no arbitrary undo depth limit, but undo is strictly sequential per entity — you cannot undo transaction 5 if transaction 8 modified the same entity + +### Activity View + +The sidebar's "Activity" link opens a transaction log view: +- Chronological list of transactions, most recent first +- Filterable by entity type, actor, date range +- Each entry shows: timestamp, actor, action summary, affected entity +- Expandable to show before/after state diff +- Undo button on eligible transactions + +### Notifications + +Every mutation triggers a toast notification: +- Brief action summary: "M3x10 SHCS assigned to MUSE 3 / B4" +- Undo button (available for a configurable duration, e.g., 10 seconds) +- After timeout, undo is still available via the Activity view +- Notifications stack, most recent on top, auto-dismiss after timeout + +--- + +## Override Visualization + +### Merge + +Merged cells render as a single region spanning all merged positions. The region's border follows the contiguous shape (may be non-rectangular). The origin position's label is displayed; alias positions show a subtle redirect indicator on hover. + +User flow: select cells to merge → preview merged region → confirm → notification with undo. + +### Divide + +Divided cells show internal subdivision lines with child labels. Each child cell is independently interactive (hover, click, assign). The parent cell's border remains visible as the outer boundary. + +User flow: select cell → choose subdivision (template-defined option or ad-hoc) → preview children → confirm → notification with undo. + +### Disable + +Disabled cells render with diagonal stripe fill and reduced opacity. Hover shows the disable reason. Disabled cells cannot receive assignments but remain visible in the grid structure. + +User flow: select cell → disable with optional reason → notification with undo. + +--- + +## Dashboard + +The sidebar's "Dashboard" link shows a summary view: +- Module cards with occupancy indicators (same as Module List but read-only summary) +- Recent activity feed (last N transactions) +- Provisional assignments queue — items needing placement, grouped by location +- Quick search bar + +--- + +## Template Browser + +Accessible from sidebar. Shows available templates: +- Card grid with template name, type (fixed/parametric), interface type provided/accepted +- Version indicator (current version number) +- Instance count (how many inserts/fixed locations use this template) +- Click to view template detail: position layout preview, version history, list of instances + +--- + +## Empty States + +Every view has a purposeful empty state with a clear next action: +- No modules → "Create your first storage module" +- Module with no levels configured → "Define this module's levels" +- Level with no insert → "Place an insert or apply a template" +- Empty grid → "Start assigning items to positions" +- No search results → "No items match. Try different terms." +- No provisional assignments → (don't show the section) + +--- + +## Visual Constants + +- Accent color: #ff6600 (orange) — selection, active states, primary actions +- Grid cell default border: neutral gray (#d1d5db) +- Occupied cell fill: light blue (#dbeafe) +- Provisional fill: dotted pattern on light amber (#fef3c7) +- Disabled fill: diagonal stripes on light red (#fee2e2) +- Search result badge colors: cycle through a palette of 8 distinguishable colors +- Hover state: border thickens + accent color +- Selected state: accent color border + subtle glow + +--- + +## Technical Notes + +### SVG Rendering + +Grid cells are SVG `` or `` elements (paths for merged non-rectangular regions). Text labels, glyphs, and badges are SVG `` and `` elements. Tooltips and detail panels are DOM elements positioned relative to SVG coordinates via `getBBox()`. + +### State Management + +Grid state is derived from: +1. Module structure (levels, location types) +2. Template definitions (positions, labeling) +3. Active inserts and their overrides +4. Current assignments (placed and provisional) +5. Active search results (for badge overlays) + +Grid re-renders on any state change. Transitions animate cell changes (new assignment fades in, removed assignment fades out). + +### Performance + +For grids up to ~500 cells, SVG performs well with React-managed elements. If a view exceeds this (unlikely for physical storage), virtualize rows outside the viewport. \ No newline at end of file diff --git a/specification/taxonomy-management-design.md b/specification/taxonomy-management-design.md new file mode 100644 index 0000000..815580f --- /dev/null +++ b/specification/taxonomy-management-design.md @@ -0,0 +1,223 @@ +# Taxonomy Management — UI/UX Specification + +## Overview + +Admin interface for managing WhereTF's item classification system: **parameter definitions**, **aspects**, **standards**, and **designations**. Categories are managed on a separate screen. + +This is a system-level tool — it shapes how all items across all orgs are described. Changes here cascade to every item that uses the affected taxonomy elements. + +--- + +## Access Model + +Taxonomy management is a **system admin** function. It is not part of the everyday item/storage workflow. + +| Role | Taxonomy | Module Layout | Items & Assignments | +|------|----------|---------------|---------------------| +| System admin | Full CRUD | Full CRUD | Full CRUD | +| Org admin | Read-only | Full CRUD | Full CRUD | +| User | Read-only | Read-only | Full CRUD | + +Auth is not implemented yet. For now, the taxonomy page is accessible to all users. When auth arrives, it moves behind a system admin gate. + +--- + +## Entity Relationships + +``` +Parameter Definition (atomic spec: name, type, unit, constraints) + └─ attached to aspects (many-to-many via aspect_parameters) + └─ attached to standards (many-to-many via standard_parameters) + └─ values stored on items (item_parameter_values) + +Aspect (reusable parameter group) + └─ contains parameter definitions + └─ optionally contains standards + └─ applied to items (many-to-many) + +Standard (classification system within an aspect) + └─ covers a subset of its parent aspect's parameters + └─ contains designations (lookup table entries) + └─ applied to items (many-to-many via item_standards) + └─ has optional domain_tag for grouping related standards (e.g., UNC/UNF → "Unified Thread Standard") + +Designation (entry in a standard's lookup table) + └─ maps a label (#8-32, M3x0.5, 0603) to parameter values + └─ values stored as compound { value, source_value?, source_unit? } +``` + +Key invariant: parameter definitions are the atomic unit. They exist independently of aspects and standards. An aspect is a curated bundle of parameter definitions. A standard is a lookup table that resolves designations to parameter values within an aspect. + +--- + +## Layout + +Single-page, two-column layout. No tabs. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Taxonomy [system admin] │ +├─────────────────────┬────────────────────────────────────────┤ +│ │ │ +│ Entity List │ Detail / Editor │ +│ │ │ +│ Section: Aspects │ Shows selected entity's full │ +│ ────────────────── │ editable form, relationships, │ +│ Section: Parameters│ standards, designations, │ +│ │ usage stats, and danger zone │ +│ │ │ +└─────────────────────┴────────────────────────────────────────┘ +``` + +**Left column** — scrollable list divided into two collapsible sections. Each section has a header with count and a "+ New" action. Selecting any entity opens its detail in the right column. + +**Right column** — detail/editor for the selected entity. Empty state: "Select an aspect or parameter to view details." Content varies by entity type (see below). + +### Why two sections, not three? + +Categories are independent of aspects and parameters. They are a separate admin screen. Aspects and parameters are tightly coupled (aspects reference parameters), so they belong together. + +--- + +## Left Column — Entity List + +### Section: Aspects + +Each row shows: +- Name +- Parameter count badge (e.g., "4 params") +- Standard count badge (e.g., "2 standards") — only if > 0 +- Item count badge (e.g., "12 items") + +Hidden aspects are excluded by default. A "Show hidden" toggle in the section header reveals them with a muted "Hidden" badge. + +Sorted alphabetically. + +### Section: Parameter Definitions + +Each row shows: +- Name +- Data type badge (`text`, `numeric`, `boolean`, `enum`) +- Unit (if set, muted) +- Aspect count badge (how many aspects use this parameter) + +Hidden parameters are excluded by default, revealed by "Show hidden" toggle. + +Sorted alphabetically. + +### Inline Create + +"+ New" in each section header expands an inline form at the top of that section (not a modal). Minimal fields: +- **Aspect**: name (required), description +- **Parameter**: name (required), data type (required), unit + +Submit creates the entity, collapses the form, and selects the new entity in the detail column. + +--- + +## Right Column — Entity Detail + +### Aspect Detail + +- Editable name and description (inline, save on blur/enter) +- **Parameters**: ordered list of attached parameter definitions. Each shows name, type badge, unit. Actions: remove from aspect. +- **Add parameter**: searchable dropdown of parameter definitions not already on this aspect. Selecting one attaches it immediately. +- **Create + add parameter**: if the needed parameter doesn't exist, inline form to create a new parameter definition and attach it in one step. Form appears below the dropdown with name, data type, unit fields. On submit, creates the parameter definition and attaches it to the aspect in a single action. +- **Standards**: list of standards belonging to this aspect. Each shows name, domain tag (if set), designation count badge. Clicking a standard opens the Standard Detail view (replaces the current detail content). +- **Add standard**: inline form — name (required), description, domain tag. Appears below the standards list. +- **Usage**: "Applied to N items" +- **Danger zone**: Hide (if in use) or Delete (if unused). See Deletion & Hiding below. + +### Standard Detail (nested within aspect) + +Accessed by clicking a standard from the Aspect Detail view. Shows a breadcrumb: `Aspect Name > Standard Name`. Clicking the aspect name in the breadcrumb returns to the Aspect Detail. + +- Editable name, description, domain tag (inline, save on blur/enter) +- **Parameters**: which of the parent aspect's parameters this standard covers. Shown as a checklist of the aspect's parameters with role selector (key/derived/info). Toggling a checkbox adds/removes the parameter from the standard. +- **Designations**: paginated table of lookup entries. + - Columns: designation string, then one column per standard parameter showing the value. Display value shown in source unit (e.g., "32 TPI"); canonical value shown muted below (e.g., "0.794 mm"). + - Actions per row: edit (inline), delete (with undo toast). + - Sorted alphabetically by designation string. +- **Add designation**: inline row at top of the table. Designation string input + value inputs for each standard parameter. Each value input includes a unit selector showing supported units for that parameter. The system converts to canonical on save and stores the source representation. +- **Usage**: "Applied to N items" +- **Danger zone**: Hide (if in use) or Delete (if unused) + +### Parameter Definition Detail + +- Editable name, data type, unit (inline, save on blur/enter) +- **Dependency warning**: changing the data type shows a warning if the parameter is referenced by aspects or standards. E.g., "Used by 3 aspects and 2 standards. Changing the type may invalidate existing values." The change is still allowed — this is informational, not blocking. +- Constraints editor (depends on data type): + - `numeric`: min, max input fields + - `enum`: tag-style editor. Each value shown as a removable tag. Add new values by typing and pressing Enter. Tags can be reordered by drag (future). + - `text`, `boolean`: no constraints +- Default value (type-appropriate input) +- **Used by aspects**: list of aspects that include this parameter. Each is a clickable link — navigates to that aspect's detail view. +- **Used by standards**: list of standards that reference this parameter. Each is a clickable link — navigates to that standard's detail view (via its parent aspect). +- **Danger zone**: Hide (if in use) or Delete (if unused) + +--- + +## Deletion & Hiding + +### Zero usage (not referenced by any items, aspects, or standards) + +Delete directly. Simple inline confirmation: "Delete [name]?" with a confirm button. No ceremony — this is a no-impact cleanup action. + +### Non-zero usage + +Elements with relationships cannot be deleted. Instead, they are **hidden** — removed from pickers and lists but preserved in the database. Existing item data remains intact. + +- Hidden elements show a muted "Hidden" badge when viewed directly (via "Show hidden" toggle in the list header). +- A hidden aspect still appears on items that already use it, but won't be offered when applying new aspects. +- A hidden standard still resolves designations on existing items, but won't appear in standard pickers. +- A hidden parameter still holds values on existing items, but won't be offered when attaching parameters to aspects. +- Unhiding restores the element to normal visibility. + +This avoids cascading data loss entirely. Full deletion with migration (reassign items, merge entities) is a future consideration. + +For designations: these are data rows, not structural elements. Delete directly with undo toast, regardless of usage — removing a designation nullifies the reference on items (ON DELETE SET NULL), which is a safe operation. + +--- + +## Empty States + +- No aspects: "No aspects defined. Aspects are reusable groups of parameters — like 'Threading' or 'Fastener Drive'." +- No parameters: "No parameter definitions. Parameters are individual specs like 'pitch' or 'drive_type'." +- Aspect with no standards: "No standards. Standards provide lookup tables that map designations to parameter values." +- Standard with no designations: "No designations. Add entries to build the lookup table." +- Detail column, nothing selected: "Select an aspect or parameter to view details." + +--- + +## Unit Conversion Helpers + +Designation value entry and item parameter display both require unit conversion. A shared library of conversion functions handles this throughout the app: + +- **Direct scaling**: inches ↔ mm, feet ↔ m, ounces ↔ grams, etc. +- **Inverse conversion**: TPI ↔ mm/thread (`25.4 / value`) +- **Non-linear formula**: AWG ↔ mm diameter (`0.127 × 92^((36 - n) / 39)`) + +Each parameter definition's canonical unit determines the target. The UI presents a unit selector alongside value inputs, and the system converts on save. The source value and source unit are stored for display. + +These helpers are implemented as pure functions in a shared module — not coupled to any UI component. + +--- + +## Future Considerations + +- **Drag-to-reorder** for parameter order within aspects and enum values +- **Merge** operation: merge two aspects, migrating all items +- **Full deletion with migration**: delete in-use elements after reassigning/migrating dependent data +- **Audit trail**: show who created/modified each taxonomy element and when +- **Bulk apply**: apply an aspect to multiple items at once from the aspect detail +- **Import/export**: bulk import of designation tables from CSV/JSON +- **Search within designations**: filter designation table by parameter value ranges +- **Visual relationship map**: graph view showing how aspects, standards, and parameters connect (inspired by Contentful's Visual Modeler) + +--- + +## Resolved Questions + +1. **Inline parameter creation**: Yes — create and attach in one step from the aspect detail. +2. **Designation value entry**: Accept values in any supported unit, convert to canonical on save. The conversion system is a shared set of helper functions used throughout the app. +3. **Large designation tables**: Not a concern. Standards are populated as items require them, not bulk-imported. Pagination is sufficient. diff --git a/specification/ui-layout-patterns.md b/specification/ui-layout-patterns.md new file mode 100644 index 0000000..4948011 --- /dev/null +++ b/specification/ui-layout-patterns.md @@ -0,0 +1,187 @@ +# UI Layout Patterns + +Standard layout patterns used across WhereTF screens. Follow these when building new screens. + +--- + +## Shell + +Every page shares a persistent shell: + +``` +┌────┬────────────────────────────────────────────────────────┐ +│ │ Topbar / breadcrumb │ +│ S ├──────────┬────────────────────────┬───────────────────┤ +│ I │ │ │ │ +│ D │ Left │ Center │ Right │ +│ E │ Panel │ Panel │ Panel │ +│ B │ ~280px │ flex: 1 │ ~320px │ +│ A │ │ │ │ +│ R │ │ │ │ +│ │ │ │ │ +└────┴──────────┴────────────────────────┴───────────────────┘ +``` + +### Sidebar (icon rail) + +- Width: 52–56px, fixed +- Background: `#1e293b`, border-right: `1px solid #334155` +- Icons: 32×32px, 6px border-radius, centered +- States: default `#94a3b8`, hover/active `#ff6600` with `#334155` background +- Links: Modules, Items, Templates, Activity (transaction log), Taxonomy (admin) +- Collapse state persists + +### Topbar + +- Height: ~44px, background: `#1e293b`, border-bottom +- Contains breadcrumb navigation +- Breadcrumb segments are clickable links; current segment is plain text + +--- + +## Three-Panel Layout + +The standard content layout. All primary screens use this pattern. + +### Left Panel — Search & Filtering + +- Width: ~280px, fixed, background: `#1e293b`, border-right +- Scrollable independently +- Contents, top to bottom: + 1. **Search input** — full-width, icon left, clear button right. Accent border on focus. + 2. **Active filter pills** — removable chips showing current filters. AND logic. Each pill: `Label: Value` with × dismiss. + 3. **Entity list / category filter** — scrollable list of selectable items (categories, entities, tree nodes). Each row: icon/indicator, name, count badge. Click to filter or navigate. Active state: accent highlight. + +### Center Panel — Primary Content + +- Flex: 1, fills remaining width +- Contains a header bar (title, count, actions) pinned at top +- Main content is a data table or grid: + - Sticky header row with sortable columns + - Frozen first column(s) (name + icon) + - Alternating row backgrounds: `#0f172a` / `#111827` + - Hover: `#1a2332` + - Selected: `#1a0f00` with 3px left accent border + - Inline cell editing on double-click +- FAB (floating action button) at bottom-right for primary create action: 48px circle, `#ff6600`, `+` icon + +### Right Panel — Detail / Properties + +- Width: ~320px, fixed, background: `#1e293b`, border-left +- Scrollable independently +- Empty state: centered muted text ("Select an item to view details") +- When populated: + 1. **Header** — entity name (editable inline, click-to-edit), description (editable) + 2. **Sections** — each with uppercase label header, border-bottom, content below + 3. **Tags/chips** — for categories, relationships. Removable (×), add button (`+ Add` with dashed border) + 4. **Collapsible groups** — chevron toggle, completeness badges + 5. **Actions** — danger zone at bottom (delete/hide) + +--- + +## Visual Constants + +| Token | Value | Usage | +|-------|-------|-------| +| Accent | `#ff6600` | Selection, active states, primary actions, FAB | +| Background (deep) | `#0f172a` | Body, inputs, table rows | +| Background (surface) | `#1e293b` | Panels, cards, headers | +| Background (hover) | `#334155` | Interactive hover states | +| Border | `#334155` | Panel borders, dividers, card borders | +| Text primary | `#e2e8f0` | Body text | +| Text secondary | `#94a3b8` | Labels, descriptions | +| Text muted | `#64748b` | Counts, hints, disabled | +| Text placeholder | `#475569` | Input placeholders, empty values | +| Danger | `#ef4444` | Delete actions, remove buttons | +| Success | `#22c55e` | Complete indicators | +| Warning | `#f59e0b` | Partial indicators | + +## Component Patterns + +### Search Input +``` +┌──────────────────────────┐ +│ 🔍 Search items... × │ +└──────────────────────────┘ +``` +- Background: `#0f172a`, border: `#334155`, focus border: `#ff6600` +- 13px font, 8px padding + +### Filter Pill +``` +┌────────────────────┐ +│ Label: Value × │ +└────────────────────┘ +``` +- Background: `#1a0f00`, border: `#ff660044`, text: `#ff6600` +- 11px font, 4px 8px padding, 12px border-radius + +### Section Header +``` +SECTION TITLE + Action +───────────────────────────────────────────────── +``` +- 10–11px uppercase, letter-spacing 0.05–0.08em, color: `#64748b` +- Border-bottom: `1px solid #334155` + +### Badge +``` +┌────────┐ +│ label │ +└────────┘ +``` +- 10px font, 2px 8px padding, 10px border-radius +- Color-coded by type: blue (receptacle), purple (fixed), green (active), red (disabled) + +### Undo Toast +- Fixed bottom-center, `#1e293b` background, border, shadow +- Message + orange "Undo" button, auto-dismiss after 5 seconds +- Animation: slide up from below + +### Dropdown Menu +- Absolute positioned, `#1e293b` background, border, shadow +- Items: 6px 12px padding, hover: `#334155` +- Max-height with scroll + +### Inline Edit +- Click element → replace with input +- Input: accent border, same font as display element +- Save on blur/Enter, cancel on Escape + +--- + +## Layout Variations + +### Two-Panel (definition/config screens) + +For screens that don't need left-panel filtering (e.g., module detail, template detail): + +``` +┌────┬──────────────────────┬────────────────────────────────┐ +│ │ Entity List / │ Preview / Grid │ +│ S │ Configuration │ + Properties sidebar │ +│ │ (panel-left) │ (panel-right) │ +└────┴──────────────────────┴────────────────────────────────┘ +``` + +- Left side: entity list or configuration form +- Right side: visual preview (SVG grid) with optional properties panel alongside + +### Card Grid (entry points) + +Module list, template list — card grid layout before drilling into detail: +- `grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))` +- Cards: `#1e293b` background, `#334155` border, hover: accent border +- 16px gap, 24px padding + +--- + +## Interaction Rules + +These apply everywhere. See [ui-paradigms.md](ui-paradigms.md) for full details. + +1. **No right-click menus** — all actions via visible UI +2. **Undo, not confirm** — destructive actions execute immediately with undo toast +3. **Click to select** — click again to deselect, Ctrl+click for multi-select +4. **Inline editing** — click/double-click to edit in place, save on blur +5. **Filter from value** — funnel icon on parameter values adds filter pill diff --git a/specification/ui-paradigms.md b/specification/ui-paradigms.md new file mode 100644 index 0000000..bd6d88c --- /dev/null +++ b/specification/ui-paradigms.md @@ -0,0 +1,23 @@ +# UI Paradigms + +Cross-cutting UI/UX rules that apply to all WhereTF interfaces. + +--- + +## No Right-Click + +WhereTF does not use right-click context menus. All actions must be reachable through visible UI elements — buttons, icons, inline controls. Context menus are invisible, undiscoverable, and inconsistent across devices (no right-click on tablets). + +--- + +## Undo, Not Confirm + +Destructive actions execute immediately with an undo toast. No confirmation dialogs. The toast auto-dismisses after a timeout. This keeps the user in flow — the cost of an accidental action is one click to undo, not a modal interrupting every deliberate action. + +--- + +## Selection + +- Click to select, click again to deselect. +- Ctrl+click for multi-select. +- Selection is visually distinct (accent border/highlight). diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..b550bc4 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,31 @@ +.git +.gitignore +.dockerignore +Dockerfile +docker-compose*.yml + +# Build outputs +.next +node_modules +out +dist +coverage + +# Editor / OS +.idea +.vscode +.DS_Store +Thumbs.db + +# Env — never bake secrets into the image +.env +.env.* +!.env.*.example + +# Tests + dev-only assets +tests +playwright-report +*.log + +# Claude local state +.claude diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..c12dde1 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local dev database +.data/ + +# agent interaction logs +logs/ + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web/.nvmrc b/web/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..5e141d1 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,85 @@ +# syntax=docker/dockerfile:1.7 + +# ────────────────────────────────────────────────────────────────────── +# WhereTF production image +# +# * Multi-stage, relies on next.config's `output: "standalone"` so the +# runtime layer has no node_modules or dev deps. +# * Non-root runtime user. +# * Multi-arch: builds linux/amd64 and linux/arm64 via buildx. +# * Health endpoints: /api/health (liveness), /api/health/ready (db). +# * No secrets in the image — DATABASE_URL is injected at runtime. +# ────────────────────────────────────────────────────────────────────── + +ARG NODE_VERSION=20 + +# ── deps ───────────────────────────────────────────────────────────── +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --include=dev + +# ── builder ────────────────────────────────────────────────────────── +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS builder +WORKDIR /app + +# Reused node_modules from deps stage so rebuilds don't re-install. +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# A dummy DATABASE_URL so Next's build step can import drizzle without +# trying to actually connect. Real URL is injected at runtime. +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATABASE_URL=postgresql://build:build@127.0.0.1:5432/build + +RUN --mount=type=cache,target=/app/.next/cache \ + npm run build + +# ── migrator ───────────────────────────────────────────────────────── +# Tiny image that exists solely to run `drizzle-kit migrate` as a +# one-shot task. Built as a separate stage so the app runner image +# doesn't have to carry drizzle-kit (dev dep). +# +# Usage: +# docker build --target migrator -t wheretf/migrate . +# docker run --rm -e DATABASE_URL=... wheretf/migrate +FROM node:${NODE_VERSION}-alpine AS migrator +WORKDIR /app +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --include=dev --no-audit --no-fund +COPY db ./db +COPY drizzle.config.ts ./drizzle.config.ts +USER node +CMD ["npx", "drizzle-kit", "migrate"] + +# ── runner ─────────────────────────────────────────────────────────── +FROM node:${NODE_VERSION}-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# Non-root user. +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Standalone server bundle + public assets + static chunks. +# Drizzle migrations ride along so the deploy system can run them via +# a one-shot container (see deployment.md). +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/db ./db +COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/web/app/_components/CellGrid.tsx b/web/app/_components/CellGrid.tsx new file mode 100644 index 0000000..c86d5db --- /dev/null +++ b/web/app/_components/CellGrid.tsx @@ -0,0 +1,433 @@ +"use client"; + +import React from "react"; + +export interface CellRow { + id: string; + label: string; + path: string; + parentId: string | null; + locationType: string; + gridRow: number | null; + gridColumn: number | null; + isDisabled: boolean; + disableReason: string | null; + maxWidthMm: string | null; + maxHeightMm: string | null; + maxDepthMm: string | null; + restrictReason: string | null; + mergedIntoId: string | null; + subdivisionSource: string | null; +} + +export interface CellAssignment { + id: string; + itemId: string; + locationId: string; + assignmentType: "placed" | "provisional"; +} + +export interface ItemRef { + id: string; + name: string; + description: string | null; +} + +/** + * Shared HTML+CSS Grid renderer for cell layouts. + * Used by both the insert detail (interactive multi-select for merge) and + * the module detail (single-select, no merge UI \u2014 caller passes an + * empty multiSelect). + * + * Caller controls behavior; this component just renders + dispatches clicks. + */ +/** + * Infer divide orientation from the children's labels. + * Vertical split (front/rear, top/bottom) when any child label hints at + * a near/far or up/down axis; horizontal (left/right, 1/2/3) otherwise. + */ +function inferDivideOrientation(children: CellRow[]): "horizontal" | "vertical" { + const verticalWords = /^(front|rear|back|top|bottom|up|down|upper|lower|near|far)$/i; + if (children.some((c) => verticalWords.test(c.label.trim()))) { + return "vertical"; + } + return "horizontal"; +} + +export function CellGrid({ + cells, + assignments, + itemsById, + selectedCellId, + multiSelect = new Set(), + onCellClick, + rowDividersFixed = false, + columnDividersFixed = false, +}: { + cells: CellRow[]; + assignments: CellAssignment[]; + itemsById: Map; + selectedCellId: string | null; + multiSelect?: Set; + onCellClick: (id: string, additive?: boolean) => void; + rowDividersFixed?: boolean; + columnDividersFixed?: boolean; +}): React.ReactElement { + const assignByLoc = new Map(); + for (const a of assignments) { + const list = assignByLoc.get(a.locationId) ?? []; + list.push(a); + assignByLoc.set(a.locationId, list); + } + + const childrenByParent = new Map(); + for (const c of cells) { + if (c.parentId) { + const list = childrenByParent.get(c.parentId) ?? []; + list.push(c); + childrenByParent.set(c.parentId, list); + } + } + + const gridCells = cells.filter( + (c) => c.gridRow != null && c.gridColumn != null + ); + if (gridCells.length === 0) { + return ( +
+ {cells.map((c) => { + const isSel = c.id === selectedCellId; + return ( + + ); + })} +
+ ); + } + + const maxRow = Math.max(...gridCells.map((c) => c.gridRow!)); + const maxCol = Math.max(...gridCells.map((c) => c.gridColumn!)); + const rows = maxRow + 1; + const cols = maxCol + 1; + + function rowLabelFor(r: number) { + const any = gridCells.find((c) => c.gridRow === r); + return any?.label.charAt(0) ?? String.fromCharCode(65 + r); + } + function colLabelFor(c: number) { + const any = gridCells.find((c2) => c2.gridColumn === c); + if (any) { + const stripped = any.label.slice(1); + if (stripped) return stripped; + } + return String(c + 1); + } + + return ( +
+
+ {Array.from({ length: cols }, (_, c) => ( +
+ {colLabelFor(c)} +
+ ))} + {Array.from({ length: rows }, (_, r) => ( +
+ {rowLabelFor(r)} +
+ ))} + + {rowDividersFixed && + Array.from({ length: rows - 1 }, (_, r) => ( +
+ ))} + {columnDividersFixed && + Array.from({ length: cols - 1 }, (_, c) => ( +
+ ))} + + {gridCells.map((cell) => { + if (cell.mergedIntoId) return null; + + const aliasChildren = gridCells.filter( + (c) => c.mergedIntoId === cell.id + ); + const mergedGroup = [cell, ...aliasChildren]; + const minR = Math.min(...mergedGroup.map((c) => c.gridRow!)); + const maxR = Math.max(...mergedGroup.map((c) => c.gridRow!)); + const minC = Math.min(...mergedGroup.map((c) => c.gridColumn!)); + const maxC = Math.max(...mergedGroup.map((c) => c.gridColumn!)); + const rowSpan = maxR - minR + 1; + const colSpan = maxC - minC + 1; + const isMerged = aliasChildren.length > 0; + + const cellAssignments = assignByLoc.get(cell.id) ?? []; + const occupied = cellAssignments.length > 0; + const isSelected = cell.id === selectedCellId; + const isMulti = multiSelect.has(cell.id); + const isProvisional = + occupied && cellAssignments[0].assignmentType === "provisional"; + const isRestricted = + cell.maxWidthMm || cell.maxHeightMm || cell.maxDepthMm; + + const divChildren = childrenByParent.get(cell.id) ?? []; + const isDivided = divChildren.length > 0; + + const itemName = occupied + ? itemsById.get(cellAssignments[0].itemId)?.name + : null; + const displayLabel = isMerged + ? cell.label + "+" + aliasChildren.map((a) => a.label).join("+") + : cell.label; + + const borderClass = isSelected + ? "border-accent border-2" + : isMulti + ? "border-accent border-2 border-dashed" + : cell.isDisabled + ? "border-red-900/60" + : occupied + ? isProvisional + ? "border-amber-800" + : "border-blue-800" + : "border-slate-700 hover:border-slate-600"; + const bgClass = cell.isDisabled + ? "bg-red-900/10" + : isSelected + ? "bg-accent/10" + : isMulti + ? "bg-accent/5" + : occupied + ? isProvisional + ? "bg-amber-900/15" + : "bg-blue-900/15" + : isMerged + ? "bg-blue-950/20" + : "bg-slate-800/30"; + const cellClasses = [ + "relative rounded border overflow-hidden transition-colors", + borderClass, + bgClass, + isDivided ? "" : "cursor-pointer", + ] + .filter(Boolean) + .join(" "); + + const cellStyle: React.CSSProperties = { + gridRow: `${minR + 2} / span ${rowSpan}`, + gridColumn: `${minC + 2} / span ${colSpan}`, + minWidth: 0, + minHeight: 0, + }; + + return ( +
onCellClick(cell.id, e.ctrlKey || e.metaKey) + } + > + {cell.isDisabled && !isDivided && ( + <> +
+ {cell.disableReason && ( +
+ {cell.disableReason} +
+ )} + + )} + + {occupied && !isDivided && ( + + )} + {isRestricted && !cell.isDisabled && !isDivided && ( + + )} + + {isDivided ? ( +
+ {divChildren.map((child, i) => { + const orientation = inferDivideOrientation(divChildren); + const childAssigns = assignByLoc.get(child.id) ?? []; + const childOccupied = childAssigns.length > 0; + const childItem = childOccupied + ? itemsById.get(childAssigns[0].itemId)?.name + : null; + const childSelected = child.id === selectedCellId; + const childMulti = multiSelect.has(child.id); + const isLast = i === divChildren.length - 1; + const sepClass = isLast + ? "" + : orientation === "vertical" + ? "border-b border-slate-700" + : "border-r border-slate-700"; + const subClasses = [ + "flex-1 flex flex-col items-center justify-center gap-0.5 px-1 py-1 cursor-pointer transition-colors min-w-0 min-h-0", + sepClass, + child.isDisabled + ? "bg-red-900/15 text-red-300" + : childSelected + ? "bg-accent/20" + : childMulti + ? "bg-accent/10" + : childOccupied + ? "bg-blue-900/20" + : "hover:bg-slate-700/30", + ] + .filter(Boolean) + .join(" "); + return ( +
{ + e.stopPropagation(); + onCellClick(child.id, e.ctrlKey || e.metaKey); + }} + className={`${subClasses} relative`} + > + {child.isDisabled && ( + <> +
+ {child.disableReason && ( +
+ {child.disableReason} +
+ )} + + )} +
+ {child.label} +
+ {childItem && ( +
+ {childItem} +
+ )} +
+ ); + })} +
+ ) : ( +
+
+ {displayLabel} +
+ {itemName && ( +
+ {itemName} +
+ )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/web/app/activity/page.tsx b/web/app/activity/page.tsx new file mode 100644 index 0000000..0863398 --- /dev/null +++ b/web/app/activity/page.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +interface Transaction { + id: string; + parentId: string | null; + actionType: string; + entityType: string; + entityId: string; + beforeState: Record | null; + afterState: Record | null; + isUndone: boolean; + undoneByTransactionId: string | null; + createdAt: string; +} + +const ENTITY_COLORS: Record = { + assignment: "bg-blue-900/40 text-blue-300", + insert: "bg-purple-900/40 text-purple-300", + location: "bg-emerald-900/40 text-emerald-300", + module: "bg-amber-900/40 text-amber-300", + template: "bg-rose-900/40 text-rose-300", + item: "bg-cyan-900/40 text-cyan-300", +}; + +function formatAction(actionType: string): string { + // "assignment.create" → "Created assignment" + const [entity, action] = actionType.split("."); + const pastTense: Record = { + create: "Created", + update: "Updated", + delete: "Deleted", + place: "Placed", + move: "Moved", + removeFromLocation: "Removed from location", + convertToPlaced: "Converted to placed", + merge: "Merged", + divide: "Divided", + disable: "Disabled", + enable: "Enabled", + publishVersion: "Published version", + setActiveVersion: "Set active version", + }; + return `${pastTense[action] || action} ${entity}`; +} + +function formatTime(dateStr: string): string { + const d = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString(); +} + +function getEntityName(state: Record | null): string | null { + if (!state) return null; + return ( + (state.name as string) || + (state.label as string) || + (state.path as string) || + null + ); +} + +export default function ActivityPage() { + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [entityFilter, setEntityFilter] = useState(""); + const [limit, setLimit] = useState(50); + + const fetchTransactions = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/transactions?limit=${limit}`); + const data = await res.json(); + setTransactions(data.transactions || []); + } catch (err) { + console.error("Failed to fetch transactions:", err); + } finally { + setLoading(false); + } + }, [limit]); + + useEffect(() => { + fetchTransactions(); + }, [fetchTransactions]); + + const filtered = entityFilter + ? transactions.filter((t) => t.entityType === entityFilter) + : transactions; + + const entityTypes = [ + ...new Set(transactions.map((t) => t.entityType)), + ].sort(); + + return ( +
+ {/* Header */} +
+

Activity

+ + {filtered.length} transaction{filtered.length !== 1 ? "s" : ""} + + + {/* Entity filter */} +
+ + {limit <= 50 && ( + + )} +
+
+ + {/* Transaction list */} +
+ {loading ? ( +
+ Loading... +
+ ) : filtered.length === 0 ? ( +
+ No transactions recorded yet. +
+ ) : ( +
+ {filtered.map((tx) => { + const isExpanded = expandedId === tx.id; + const entityName = + getEntityName(tx.afterState) || + getEntityName(tx.beforeState); + const colorClass = + ENTITY_COLORS[tx.entityType] || "bg-slate-700 text-slate-300"; + + return ( +
+ + + {isExpanded && ( +
+
+
+

+ Before +

+
+                            {tx.beforeState
+                              ? JSON.stringify(tx.beforeState, null, 2)
+                              : "null"}
+                          
+
+
+

+ After +

+
+                            {tx.afterState
+                              ? JSON.stringify(tx.afterState, null, 2)
+                              : "null"}
+                          
+
+
+

+ {tx.id} +

+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/web/app/api/aspects/[id]/items/route.ts b/web/app/api/aspects/[id]/items/route.ts new file mode 100644 index 0000000..55033cd --- /dev/null +++ b/web/app/api/aspects/[id]/items/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { aspectRepository } from "@/repositories/aspectRepository"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const limitStr = searchParams.get("limit"); + const limit = limitStr ? parseInt(limitStr, 10) : 50; + const items = await aspectRepository.listItemsUsing({ + aspectId: id, + limit: Number.isFinite(limit) && limit > 0 ? limit : 50, + }); + return NextResponse.json({ items }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/aspects/[id]/parameters/route.ts b/web/app/api/aspects/[id]/parameters/route.ts new file mode 100644 index 0000000..2770749 --- /dev/null +++ b/web/app/api/aspects/[id]/parameters/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { aspectRepository } from "@/repositories/aspectRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const parameters = await aspectRepository.getParameters({ aspectId: id }); + return NextResponse.json({ parameters }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const parameter = await aspectRepository.addParameter({ + aspectId: id, + ...body, + }); + return NextResponse.json({ parameter }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { parameterDefinitionId } = await request.json(); + await aspectRepository.removeParameter({ + aspectId: id, + parameterDefinitionId, + }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/aspects/[id]/route.ts b/web/app/api/aspects/[id]/route.ts new file mode 100644 index 0000000..2683af0 --- /dev/null +++ b/web/app/api/aspects/[id]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { aspectRepository } from "@/repositories/aspectRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const aspect = await aspectRepository.findById({ id }); + if (!aspect) { + return NextResponse.json({ error: "Aspect not found" }, { status: 404 }); + } + const [usage, parameters] = await Promise.all([ + aspectRepository.getUsage({ aspectId: id }), + aspectRepository.getParameters({ aspectId: id }), + ]); + return NextResponse.json({ + aspect, + parameters, + itemCount: usage.itemCount, + parameterCount: usage.parameterCount, + standardCount: usage.standardCount, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const aspect = await aspectRepository.update({ id, ...body }); + return NextResponse.json({ aspect }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await aspectRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/aspects/[id]/suggested-parameters/route.ts b/web/app/api/aspects/[id]/suggested-parameters/route.ts new file mode 100644 index 0000000..04ef05b --- /dev/null +++ b/web/app/api/aspects/[id]/suggested-parameters/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { aspectRepository } from "@/repositories/aspectRepository"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const limitStr = searchParams.get("limit"); + const limit = limitStr ? parseInt(limitStr, 10) : 5; + const suggestions = await aspectRepository.suggestCoOccurringParameters({ + aspectId: id, + limit: Number.isFinite(limit) && limit > 0 ? limit : 5, + }); + return NextResponse.json({ suggestions }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/aspects/route.ts b/web/app/api/aspects/route.ts new file mode 100644 index 0000000..353b1d9 --- /dev/null +++ b/web/app/api/aspects/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { aspectRepository } from "@/repositories/aspectRepository"; + +export async function GET() { + try { + const aspects = await aspectRepository.listWithUsage(); + return NextResponse.json({ aspects }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const aspect = await aspectRepository.create(body); + return NextResponse.json({ aspect }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/assignments/[id]/convert/route.ts b/web/app/api/assignments/[id]/convert/route.ts new file mode 100644 index 0000000..051f56f --- /dev/null +++ b/web/app/api/assignments/[id]/convert/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assignmentRepository } from "@/repositories/assignmentRepository"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const assignment = await assignmentRepository.convertToPlaced({ + id, + locationId: body.locationId, + }); + return NextResponse.json({ assignment }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if (message.includes("not co-storable")) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/assignments/[id]/move/route.ts b/web/app/api/assignments/[id]/move/route.ts new file mode 100644 index 0000000..88390eb --- /dev/null +++ b/web/app/api/assignments/[id]/move/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assignmentRepository } from "@/repositories/assignmentRepository"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const assignment = await assignmentRepository.move({ + id, + newLocationId: body.locationId, + }); + return NextResponse.json({ assignment }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if (message.includes("not co-storable")) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/assignments/[id]/route.ts b/web/app/api/assignments/[id]/route.ts new file mode 100644 index 0000000..d1554f1 --- /dev/null +++ b/web/app/api/assignments/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assignmentRepository } from "@/repositories/assignmentRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const assignment = await assignmentRepository.findById({ id }); + if (!assignment) { + return NextResponse.json( + { error: "Assignment not found" }, + { status: 404 }, + ); + } + return NextResponse.json({ assignment }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await assignmentRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/assignments/route.ts b/web/app/api/assignments/route.ts new file mode 100644 index 0000000..ddf3150 --- /dev/null +++ b/web/app/api/assignments/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assignmentRepository } from "@/repositories/assignmentRepository"; + +export async function GET(request: NextRequest) { + try { + const itemId = request.nextUrl.searchParams.get("itemId"); + const locationId = request.nextUrl.searchParams.get("locationId"); + const provisional = request.nextUrl.searchParams.get("provisional"); + + if (itemId) { + const assignments = await assignmentRepository.findByItemId({ itemId }); + return NextResponse.json({ assignments }); + } + if (locationId) { + const assignments = await assignmentRepository.findByLocationId({ + locationId, + }); + return NextResponse.json({ assignments }); + } + if (provisional === "true") { + const assignments = await assignmentRepository.listProvisional(); + return NextResponse.json({ assignments }); + } + + return NextResponse.json( + { + error: + "A filter is required: itemId, locationId, or provisional=true", + }, + { status: 400 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const assignment = await assignmentRepository.create(body); + return NextResponse.json({ assignment }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not co-storable")) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/categories/[id]/items/route.ts b/web/app/api/categories/[id]/items/route.ts new file mode 100644 index 0000000..1174585 --- /dev/null +++ b/web/app/api/categories/[id]/items/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { categoryRepository } from "@/repositories/categoryRepository"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const limitStr = searchParams.get("limit"); + const limit = limitStr ? parseInt(limitStr, 10) : 50; + const items = await categoryRepository.listItems({ + categoryId: id, + limit: Number.isFinite(limit) && limit > 0 ? limit : 50, + }); + return NextResponse.json({ items }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/categories/[id]/route.ts b/web/app/api/categories/[id]/route.ts new file mode 100644 index 0000000..005ddda --- /dev/null +++ b/web/app/api/categories/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { categoryRepository } from "@/repositories/categoryRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const category = await categoryRepository.findById({ id }); + if (!category) { + return NextResponse.json({ error: "Category not found" }, { status: 404 }); + } + return NextResponse.json({ category }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const category = await categoryRepository.update({ id, ...body }); + return NextResponse.json({ category }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await categoryRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/categories/counts/route.ts b/web/app/api/categories/counts/route.ts new file mode 100644 index 0000000..0b0c58b --- /dev/null +++ b/web/app/api/categories/counts/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET(request: NextRequest) { + try { + const params = request.nextUrl.searchParams; + const q = params.get("q") || undefined; + + // Parse filter params same as items route + const filterParam = params.get("filter"); + let filters: + | { parameterDefinitionId: string; value: unknown }[] + | undefined; + if (filterParam) { + filters = filterParam.split(",").map((f) => { + const colonIdx = f.indexOf(":"); + const parameterDefinitionId = f.slice(0, colonIdx); + const rawValue = f.slice(colonIdx + 1); + let value: unknown; + try { + value = JSON.parse(rawValue); + } catch { + value = rawValue; + } + return { parameterDefinitionId, value }; + }); + } + + const categories = await itemRepository.getCategoryCounts({ + query: q, + filters, + }); + + return NextResponse.json({ categories }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} diff --git a/web/app/api/categories/route.ts b/web/app/api/categories/route.ts new file mode 100644 index 0000000..4ec7e17 --- /dev/null +++ b/web/app/api/categories/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { categoryRepository } from "@/repositories/categoryRepository"; + +export async function GET() { + try { + const categories = await categoryRepository.listWithUsage(); + return NextResponse.json({ categories }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const category = await categoryRepository.create(body); + return NextResponse.json({ category }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/health/ready/route.ts b/web/app/api/health/ready/route.ts new file mode 100644 index 0000000..60b4ce9 --- /dev/null +++ b/web/app/api/health/ready/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { sql } from "drizzle-orm"; +import { db } from "@/db/connection"; + +// Readiness probe. Performs a trivial round-trip against Postgres so +// the deploy system can gate rolling deploys on actual DB connectivity +// rather than mere process existence. +export async function GET() { + try { + await db.execute(sql`SELECT 1`); + return NextResponse.json({ status: "ready" }); + } catch (err) { + return NextResponse.json( + { + status: "not_ready", + reason: err instanceof Error ? err.message : "db check failed", + }, + { status: 503 } + ); + } +} diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts new file mode 100644 index 0000000..85bae21 --- /dev/null +++ b/web/app/api/health/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; + +// Liveness probe. Returns 200 as long as the Node process is servicing +// HTTP — does not talk to Postgres. Use /api/health/ready for a full +// readiness check. +export async function GET() { + return NextResponse.json({ status: "ok" }); +} diff --git a/web/app/api/inserts/[id]/compatible-receptacles/route.ts b/web/app/api/inserts/[id]/compatible-receptacles/route.ts new file mode 100644 index 0000000..95772aa --- /dev/null +++ b/web/app/api/inserts/[id]/compatible-receptacles/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { insertRepository } from "@/repositories/insertRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const receptacles = await insertRepository.listCompatibleReceptacles({ id }); + return NextResponse.json({ receptacles }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/inserts/[id]/place/route.ts b/web/app/api/inserts/[id]/place/route.ts new file mode 100644 index 0000000..066c63f --- /dev/null +++ b/web/app/api/inserts/[id]/place/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { insertRepository } from "@/repositories/insertRepository"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const insert = await insertRepository.place({ + id, + locationId: body.locationId, + }); + return NextResponse.json({ insert }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if (message.includes("mismatch") || message.includes("not a receptacle")) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const insert = await insertRepository.removeFromLocation({ id }); + return NextResponse.json({ insert }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/inserts/[id]/route.ts b/web/app/api/inserts/[id]/route.ts new file mode 100644 index 0000000..4a36510 --- /dev/null +++ b/web/app/api/inserts/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { insertRepository } from "@/repositories/insertRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const insert = await insertRepository.findById({ id }); + if (!insert) { + return NextResponse.json( + { error: "Insert not found" }, + { status: 404 }, + ); + } + return NextResponse.json({ insert }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const insert = await insertRepository.update({ id, ...body }); + return NextResponse.json({ insert }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await insertRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/inserts/place-with-children/route.ts b/web/app/api/inserts/place-with-children/route.ts new file mode 100644 index 0000000..f70cd49 --- /dev/null +++ b/web/app/api/inserts/place-with-children/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { insertRepository } from "@/repositories/insertRepository"; +import { locationRepository } from "@/repositories/locationRepository"; +import { db } from "@/db/connection"; +import { locations } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +/** + * Compound operation retained for API compatibility: + * create insert + place it. Cell materialization now happens inside + * insertRepository.create(); place() re-parents cells to the receptacle. + * Body: { templateId, templateVersionId, locationId, name?, rows?, columns? } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { templateId, templateVersionId, locationId, name, rows, columns } = + body; + + if (!templateId || !templateVersionId || !locationId) { + return NextResponse.json( + { error: "templateId, templateVersionId, and locationId are required" }, + { status: 400 } + ); + } + + const parent = await locationRepository.findById({ id: locationId }); + if (!parent) { + return NextResponse.json( + { error: "Location not found" }, + { status: 404 } + ); + } + + const insert = await insertRepository.create({ + name, + templateId, + templateVersionId, + rows, + columns, + }); + + await insertRepository.place({ id: insert.id, locationId }); + + const cells = await db + .select() + .from(locations) + .where(eq(locations.insertId, insert.id)); + + return NextResponse.json( + { insert, locations: cells }, + { status: 201 } + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/inserts/route.ts b/web/app/api/inserts/route.ts new file mode 100644 index 0000000..b4364a9 --- /dev/null +++ b/web/app/api/inserts/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { insertRepository } from "@/repositories/insertRepository"; + +export async function GET(request: NextRequest) { + try { + const params = request.nextUrl.searchParams; + + // Legacy: ?unplaced=true returns raw list without joins. + if (params.get("unplaced") === "true") { + const inserts = await insertRepository.listUnplaced(); + return NextResponse.json({ inserts }); + } + + const placementParam = params.get("placement"); + const placement: "placed" | "unplaced" | "all" = + placementParam === "placed" || placementParam === "unplaced" + ? placementParam + : "all"; + + const inserts = await insertRepository.listWithDetails({ + templateId: params.get("templateId") ?? undefined, + interfaceType: params.get("interfaceType") ?? undefined, + moduleId: params.get("moduleId") ?? undefined, + placement, + }); + return NextResponse.json({ inserts }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const insert = await insertRepository.create(body); + return NextResponse.json({ insert }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/interface-types/[id]/route.ts b/web/app/api/interface-types/[id]/route.ts new file mode 100644 index 0000000..44b68d0 --- /dev/null +++ b/web/app/api/interface-types/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const interfaceType = await interfaceTypeRepository.findById({ id }); + if (!interfaceType) { + return NextResponse.json( + { error: "Interface type not found" }, + { status: 404 }, + ); + } + return NextResponse.json({ interfaceType }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const interfaceType = await interfaceTypeRepository.update({ + id, + ...body, + }); + return NextResponse.json({ interfaceType }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await interfaceTypeRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/interface-types/route.ts b/web/app/api/interface-types/route.ts new file mode 100644 index 0000000..197eaed --- /dev/null +++ b/web/app/api/interface-types/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { interfaceTypeRepository } from "@/repositories/interfaceTypeRepository"; + +export async function GET() { + try { + const interfaceTypes = await interfaceTypeRepository.list(); + return NextResponse.json({ interfaceTypes }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const interfaceType = await interfaceTypeRepository.create(body); + return NextResponse.json({ interfaceType }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/items/[id]/aspects/route.ts b/web/app/api/items/[id]/aspects/route.ts new file mode 100644 index 0000000..e8a439f --- /dev/null +++ b/web/app/api/items/[id]/aspects/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const aspects = await itemRepository.getAspects({ itemId: id }); + return NextResponse.json({ aspects }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { aspectId } = await request.json(); + const itemAspect = await itemRepository.applyAspect({ + itemId: id, + aspectId, + }); + return NextResponse.json({ itemAspect }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { aspectId } = await request.json(); + await itemRepository.removeAspect({ itemId: id, aspectId }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not applied")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/items/[id]/categories/route.ts b/web/app/api/items/[id]/categories/route.ts new file mode 100644 index 0000000..caafbaf --- /dev/null +++ b/web/app/api/items/[id]/categories/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const categories = await itemRepository.getCategories({ itemId: id }); + return NextResponse.json({ categories }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const itemCategory = await itemRepository.addCategory({ + itemId: id, + ...body, + }); + return NextResponse.json({ itemCategory }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const { categoryId } = await request.json(); + await itemRepository.removeCategory({ itemId: id, categoryId }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not on item")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/items/[id]/co-storable/route.ts b/web/app/api/items/[id]/co-storable/route.ts new file mode 100644 index 0000000..2cd2397 --- /dev/null +++ b/web/app/api/items/[id]/co-storable/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const items = await itemRepository.getCoStorableItems({ itemId: id }); + return NextResponse.json({ items }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + await itemRepository.addCoStorability({ + itemAId: id, + itemBId: body.itemId, + reason: body.reason, + }); + return NextResponse.json({ success: true }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + await itemRepository.removeCoStorability({ + itemAId: id, + itemBId: body.itemId, + }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/items/[id]/parameters/route.ts b/web/app/api/items/[id]/parameters/route.ts new file mode 100644 index 0000000..9b0e342 --- /dev/null +++ b/web/app/api/items/[id]/parameters/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const parameters = await itemRepository.getParameterValues({ itemId: id }); + return NextResponse.json({ parameters }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const parameter = await itemRepository.setParameterValue({ + itemId: id, + ...body, + }); + return NextResponse.json({ parameter }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/items/[id]/route.ts b/web/app/api/items/[id]/route.ts new file mode 100644 index 0000000..27f72d8 --- /dev/null +++ b/web/app/api/items/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const item = await itemRepository.findById({ id }); + if (!item) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + return NextResponse.json({ item }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const item = await itemRepository.update({ id, ...body }); + return NextResponse.json({ item }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await itemRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/items/[id]/standards/route.ts b/web/app/api/items/[id]/standards/route.ts new file mode 100644 index 0000000..4b75830 --- /dev/null +++ b/web/app/api/items/[id]/standards/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { standardRepository } from "@/repositories/standardRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const standards = await standardRepository.getItemStandards({ itemId: id }); + return NextResponse.json({ standards }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const { standardId, designationId } = body; + + if (!standardId) { + return NextResponse.json( + { error: "standardId is required" }, + { status: 400 } + ); + } + + const itemStandard = await standardRepository.applyToItem({ + itemId: id, + standardId, + designationId, + }); + + return NextResponse.json({ itemStandard }, { status: 201 }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const { standardId, designationId } = body; + + if (!standardId) { + return NextResponse.json( + { error: "standardId is required" }, + { status: 400 } + ); + } + + const updated = await standardRepository.setDesignation({ + itemId: id, + standardId, + designationId, + }); + + return NextResponse.json({ itemStandard: updated }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const standardId = searchParams.get("standardId"); + + if (!standardId) { + return NextResponse.json( + { error: "standardId query param is required" }, + { status: 400 } + ); + } + + await standardRepository.removeFromItem({ itemId: id, standardId }); + return NextResponse.json({ success: true }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/items/find-similar/route.ts b/web/app/api/items/find-similar/route.ts new file mode 100644 index 0000000..e7d9435 --- /dev/null +++ b/web/app/api/items/find-similar/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const standardId = searchParams.get("standardId"); + const designationId = searchParams.get("designationId"); + if (!standardId || !designationId) { + return NextResponse.json( + { error: "standardId and designationId are required" }, + { status: 400 } + ); + } + const candidates = await itemRepository.findSimilar({ + standardId, + designationId, + }); + return NextResponse.json({ candidates }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/items/route.ts b/web/app/api/items/route.ts new file mode 100644 index 0000000..a566a4b --- /dev/null +++ b/web/app/api/items/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET(request: NextRequest) { + try { + const params = request.nextUrl.searchParams; + const q = params.get("q") || undefined; + const categoryId = params.get("category") || undefined; + const sortBy = params.get("sort") || undefined; + const sortDirection = (params.get("dir") || "asc") as "asc" | "desc"; + + // Parse filter params: filter=paramDefId:value,paramDefId:value + const filterParam = params.get("filter"); + let filters: { parameterDefinitionId: string; value: unknown }[] | undefined; + if (filterParam) { + filters = filterParam.split(",").map((f) => { + const colonIdx = f.indexOf(":"); + const parameterDefinitionId = f.slice(0, colonIdx); + const rawValue = f.slice(colonIdx + 1); + // Try parsing as JSON for numbers/booleans, fall back to string + let value: unknown; + try { + value = JSON.parse(rawValue); + } catch { + value = rawValue; + } + return { parameterDefinitionId, value }; + }); + } + + const result = await itemRepository.listRich({ + query: q, + filters, + categoryId, + sortBy, + sortDirection, + }); + + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const item = await itemRepository.create(body); + return NextResponse.json({ item }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/items/suggest-categories/route.ts b/web/app/api/items/suggest-categories/route.ts new file mode 100644 index 0000000..754e02d --- /dev/null +++ b/web/app/api/items/suggest-categories/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const aspectIds = searchParams.getAll("aspectId"); + const standardIds = searchParams.getAll("standardId"); + const limitStr = searchParams.get("limit"); + const limit = limitStr ? parseInt(limitStr, 10) : 3; + + const suggestions = await itemRepository.suggestCategories({ + aspectIds, + standardIds, + limit: Number.isFinite(limit) && limit > 0 ? limit : 3, + }); + + return NextResponse.json({ suggestions }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/locations/[id]/disable/route.ts b/web/app/api/locations/[id]/disable/route.ts new file mode 100644 index 0000000..05a69d1 --- /dev/null +++ b/web/app/api/locations/[id]/disable/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const location = await locationRepository.disable({ + id, + reason: body.reason, + }); + return NextResponse.json({ location }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if (message.includes("active assignments")) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const location = await locationRepository.enable({ id }); + return NextResponse.json({ location }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/locations/[id]/divide/route.ts b/web/app/api/locations/[id]/divide/route.ts new file mode 100644 index 0000000..cbc13c8 --- /dev/null +++ b/web/app/api/locations/[id]/divide/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + const { labels, source } = body ?? {}; + if (!Array.isArray(labels)) { + return NextResponse.json( + { error: "labels[] required" }, + { status: 400 } + ); + } + const children = await locationRepository.divide({ + parentId: id, + labels, + source, + }); + return NextResponse.json({ children }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if ( + message.includes("active assignments") || + message.includes("already divided") || + message.includes("unique") || + message.includes("non-empty") || + message.includes("at least two") + ) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const result = await locationRepository.undivide({ parentId: id }); + return NextResponse.json({ undivide: result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found") || message.includes("not divided")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if ( + message.includes("assignments") || + message.includes("subdivided") + ) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/locations/[id]/restrict/route.ts b/web/app/api/locations/[id]/restrict/route.ts new file mode 100644 index 0000000..26746cd --- /dev/null +++ b/web/app/api/locations/[id]/restrict/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const { maxWidthMm, maxHeightMm, maxDepthMm, reason } = body ?? {}; + const location = await locationRepository.restrict({ + id, + maxWidthMm: normalizeNum(maxWidthMm), + maxHeightMm: normalizeNum(maxHeightMm), + maxDepthMm: normalizeNum(maxDepthMm), + reason: typeof reason === "string" ? reason : null, + }); + return NextResponse.json({ location }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: RouteParams +) { + try { + const { id } = await params; + const location = await locationRepository.clearRestrict({ id }); + return NextResponse.json({ location }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +function normalizeNum(v: unknown): number | null { + if (v == null || v === "") return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && n > 0 ? n : null; +} diff --git a/web/app/api/locations/[id]/route.ts b/web/app/api/locations/[id]/route.ts new file mode 100644 index 0000000..2fbb511 --- /dev/null +++ b/web/app/api/locations/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const location = await locationRepository.findById({ id }); + if (!location) { + return NextResponse.json( + { error: "Location not found" }, + { status: 404 }, + ); + } + return NextResponse.json({ location }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const location = await locationRepository.update({ id, ...body }); + return NextResponse.json({ location }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await locationRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/locations/[id]/unmerge/route.ts b/web/app/api/locations/[id]/unmerge/route.ts new file mode 100644 index 0000000..1373df6 --- /dev/null +++ b/web/app/api/locations/[id]/unmerge/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function POST(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const result = await locationRepository.unmerge({ originId: id }); + return NextResponse.json({ unmerge: result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("No merged")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/locations/merge/route.ts b/web/app/api/locations/merge/route.ts new file mode 100644 index 0000000..847237b --- /dev/null +++ b/web/app/api/locations/merge/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { originId, aliasIds } = body ?? {}; + if (!originId || !Array.isArray(aliasIds)) { + return NextResponse.json( + { error: "originId and aliasIds[] required" }, + { status: 400 } + ); + } + const result = await locationRepository.merge({ originId, aliasIds }); + return NextResponse.json({ merge: result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + if ( + message.includes("active assignments") || + message.includes("fixed") || + message.includes("contiguous") || + message.includes("same parent") || + message.includes("same insert") || + message.includes("already merged") || + message.includes("disabled cells") + ) { + return NextResponse.json({ error: message }, { status: 409 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/locations/route.ts b/web/app/api/locations/route.ts new file mode 100644 index 0000000..9ad6f7e --- /dev/null +++ b/web/app/api/locations/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { locationRepository } from "@/repositories/locationRepository"; + +export async function GET(request: NextRequest) { + try { + const moduleId = request.nextUrl.searchParams.get("moduleId"); + const insertId = request.nextUrl.searchParams.get("insertId"); + if (insertId) { + const locations = await locationRepository.findByInsertId({ insertId }); + return NextResponse.json({ locations }); + } + if (!moduleId) { + return NextResponse.json( + { error: "moduleId or insertId query parameter is required" }, + { status: 400 }, + ); + } + const locations = await locationRepository.findByModuleId({ moduleId }); + return NextResponse.json({ locations }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const location = await locationRepository.create(body); + return NextResponse.json({ location }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/modules/[id]/route.ts b/web/app/api/modules/[id]/route.ts new file mode 100644 index 0000000..40fa27f --- /dev/null +++ b/web/app/api/modules/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { moduleRepository } from "@/repositories/moduleRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const mod = await moduleRepository.findById({ id }); + if (!mod) { + return NextResponse.json({ error: "Module not found" }, { status: 404 }); + } + return NextResponse.json({ module: mod }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + const { name, description, primaryDimensionLabel, primaryDimensionCount, metadata } = body; + + const mod = await moduleRepository.update({ + id, + name, + description, + primaryDimensionLabel, + primaryDimensionCount, + metadata, + }); + + return NextResponse.json({ module: mod }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const cascade = request.nextUrl.searchParams.get("cascade") === "true"; + if (cascade) { + const stats = await moduleRepository.removeWithCascade({ id }); + return NextResponse.json({ success: true, stats }); + } + await moduleRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/modules/[id]/stats/route.ts b/web/app/api/modules/[id]/stats/route.ts new file mode 100644 index 0000000..6328a35 --- /dev/null +++ b/web/app/api/modules/[id]/stats/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { moduleRepository } from "@/repositories/moduleRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const stats = await moduleRepository.getStats({ id }); + return NextResponse.json({ stats }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/modules/route.ts b/web/app/api/modules/route.ts new file mode 100644 index 0000000..f3a1be9 --- /dev/null +++ b/web/app/api/modules/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { moduleRepository } from "@/repositories/moduleRepository"; + +export async function GET() { + try { + const modules = await moduleRepository.list(); + return NextResponse.json({ modules }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, description, primaryDimensionLabel, primaryDimensionCount, metadata } = body; + + if (!name || !primaryDimensionLabel || primaryDimensionCount == null) { + return NextResponse.json( + { error: "name, primaryDimensionLabel, and primaryDimensionCount are required" }, + { status: 400 } + ); + } + + const mod = await moduleRepository.create({ + name, + description, + primaryDimensionLabel, + primaryDimensionCount, + metadata, + }); + + return NextResponse.json({ module: mod }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/parameter-definitions/[id]/route.ts b/web/app/api/parameter-definitions/[id]/route.ts new file mode 100644 index 0000000..6d5afbd --- /dev/null +++ b/web/app/api/parameter-definitions/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const parameterDefinition = await parameterDefinitionRepository.findById({ id }); + if (!parameterDefinition) { + return NextResponse.json({ error: "Parameter definition not found" }, { status: 404 }); + } + return NextResponse.json({ parameterDefinition }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const parameterDefinition = await parameterDefinitionRepository.update({ id, ...body }); + return NextResponse.json({ parameterDefinition }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + await parameterDefinitionRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unexpected error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/web/app/api/parameter-definitions/[id]/usage/route.ts b/web/app/api/parameter-definitions/[id]/usage/route.ts new file mode 100644 index 0000000..d4639fd --- /dev/null +++ b/web/app/api/parameter-definitions/[id]/usage/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const usage = await parameterDefinitionRepository.getUsage({ + parameterDefinitionId: id, + }); + return NextResponse.json(usage); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/parameter-definitions/route.ts b/web/app/api/parameter-definitions/route.ts new file mode 100644 index 0000000..9f3a313 --- /dev/null +++ b/web/app/api/parameter-definitions/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; + +export async function GET() { + try { + const parameterDefinitions = + await parameterDefinitionRepository.listWithUsage(); + return NextResponse.json({ parameterDefinitions }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const parameterDefinition = await parameterDefinitionRepository.create(body); + return NextResponse.json({ parameterDefinition }, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 400 }, + ); + } +} diff --git a/web/app/api/standards/[id]/aspects/route.ts b/web/app/api/standards/[id]/aspects/route.ts new file mode 100644 index 0000000..158dd97 --- /dev/null +++ b/web/app/api/standards/[id]/aspects/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { standardRepository } from "@/repositories/standardRepository"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: standardId } = await params; + const body = await request.json(); + const { aspectId } = body; + + if (!aspectId) { + return NextResponse.json( + { error: "aspectId is required" }, + { status: 400 } + ); + } + + const link = await standardRepository.addAspect({ standardId, aspectId }); + return NextResponse.json({ link }, { status: 201 }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: standardId } = await params; + const { searchParams } = new URL(request.url); + const aspectId = searchParams.get("aspectId"); + + if (!aspectId) { + return NextResponse.json( + { error: "aspectId query parameter is required" }, + { status: 400 } + ); + } + + await standardRepository.removeAspect({ standardId, aspectId }); + return NextResponse.json({ success: true }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/standards/[id]/designations/route.ts b/web/app/api/standards/[id]/designations/route.ts new file mode 100644 index 0000000..ac0328c --- /dev/null +++ b/web/app/api/standards/[id]/designations/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { standardRepository } from "@/repositories/standardRepository"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const limit = searchParams.get("limit") + ? parseInt(searchParams.get("limit")!, 10) + : 50; + const offset = searchParams.get("offset") + ? parseInt(searchParams.get("offset")!, 10) + : 0; + const q = searchParams.get("q") ?? undefined; + + const [designations, total] = await Promise.all([ + standardRepository.listDesignations({ standardId: id, q, limit, offset }), + standardRepository.countDesignations({ standardId: id }), + ]); + + return NextResponse.json({ designations, total, limit, offset }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const { designation, values, metadata } = body; + + if (!designation || !values) { + return NextResponse.json( + { error: "designation and values are required" }, + { status: 400 } + ); + } + + const entry = await standardRepository.createDesignation({ + standardId: id, + designation, + values, + metadata, + }); + + return NextResponse.json({ designation: entry }, { status: 201 }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const designationId = new URL(request.url).searchParams.get("id"); + if (!designationId) { + return NextResponse.json( + { error: "id query parameter is required" }, + { status: 400 } + ); + } + await standardRepository.removeDesignation({ id: designationId }); + return NextResponse.json({ success: true }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + const status = message.includes("not found") ? 404 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/web/app/api/standards/[id]/parameters/route.ts b/web/app/api/standards/[id]/parameters/route.ts new file mode 100644 index 0000000..128f17d --- /dev/null +++ b/web/app/api/standards/[id]/parameters/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { standardRepository } from "@/repositories/standardRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const parameters = await standardRepository.getParameters({ standardId: id }); + return NextResponse.json({ parameters }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const { parameterDefinitionId, role, sortOrder } = body; + + if (!parameterDefinitionId || !role) { + return NextResponse.json( + { error: "parameterDefinitionId and role are required" }, + { status: 400 } + ); + } + + const parameter = await standardRepository.addParameter({ + standardId: id, + parameterDefinitionId, + role, + sortOrder, + }); + + return NextResponse.json({ parameter }, { status: 201 }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { searchParams } = new URL(request.url); + const parameterDefinitionId = searchParams.get("parameterDefinitionId"); + + if (!parameterDefinitionId) { + return NextResponse.json( + { error: "parameterDefinitionId query param is required" }, + { status: 400 } + ); + } + + await standardRepository.removeParameter({ + standardId: id, + parameterDefinitionId, + }); + + return NextResponse.json({ success: true }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/standards/[id]/route.ts b/web/app/api/standards/[id]/route.ts new file mode 100644 index 0000000..c37d581 --- /dev/null +++ b/web/app/api/standards/[id]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { standardRepository } from "@/repositories/standardRepository"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const standard = await standardRepository.findById({ id }); + if (!standard) { + return NextResponse.json({ error: "Standard not found" }, { status: 404 }); + } + + const [parameters, aspects, itemCount, designationCount, items, designationUsage] = + await Promise.all([ + standardRepository.getParameters({ standardId: id }), + standardRepository.listAspectsForStandard({ standardId: id }), + standardRepository.countItemsUsing({ standardId: id }), + standardRepository.countDesignations({ standardId: id }), + standardRepository.listItemsUsing({ standardId: id, limit: 50 }), + standardRepository.designationUsage({ standardId: id }), + ]); + + return NextResponse.json({ + standard, + parameters, + aspects, + itemCount, + designationCount, + items, + designationUsage, + }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const standard = await standardRepository.update({ id, ...body }); + return NextResponse.json({ standard }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + await standardRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/standards/route.ts b/web/app/api/standards/route.ts new file mode 100644 index 0000000..b8c5daf --- /dev/null +++ b/web/app/api/standards/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { standardRepository } from "@/repositories/standardRepository"; + +export async function GET() { + try { + const items = await standardRepository.list(); + return NextResponse.json({ standards: items }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, description, domainTag, aspectIds } = body; + + if (!name) { + return NextResponse.json( + { error: "name is required" }, + { status: 400 } + ); + } + + const standard = await standardRepository.create({ + name, + description, + domainTag, + }); + + if (Array.isArray(aspectIds) && aspectIds.length > 0) { + for (const aspectId of aspectIds) { + await standardRepository.addAspect({ + standardId: standard.id, + aspectId, + }); + } + } + + return NextResponse.json({ standard }, { status: 201 }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/taxonomy/audit/route.ts b/web/app/api/taxonomy/audit/route.ts new file mode 100644 index 0000000..b0fda11 --- /dev/null +++ b/web/app/api/taxonomy/audit/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { aspectRepository } from "@/repositories/aspectRepository"; +import { parameterDefinitionRepository } from "@/repositories/parameterDefinitionRepository"; +import { itemRepository } from "@/repositories/itemRepository"; + +export async function GET() { + try { + const [paramChecks, aspectChecks, valueChecks] = await Promise.all([ + parameterDefinitionRepository.audit(), + aspectRepository.audit(), + itemRepository.auditParameterValues(), + ]); + const checks = [...paramChecks, ...aspectChecks, ...valueChecks]; + return NextResponse.json({ + checks, + runAt: new Date().toISOString(), + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 } + ); + } +} diff --git a/web/app/api/templates/[id]/hide/route.ts b/web/app/api/templates/[id]/hide/route.ts new file mode 100644 index 0000000..e6a9969 --- /dev/null +++ b/web/app/api/templates/[id]/hide/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { templateRepository } from "@/repositories/templateRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function POST(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const template = await templateRepository.hide({ id }); + return NextResponse.json({ template }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const template = await templateRepository.unhide({ id }); + return NextResponse.json({ template }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/templates/[id]/route.ts b/web/app/api/templates/[id]/route.ts new file mode 100644 index 0000000..214faac --- /dev/null +++ b/web/app/api/templates/[id]/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { templateRepository } from "@/repositories/templateRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const template = await templateRepository.findById({ id }); + if (!template) { + return NextResponse.json({ error: "Template not found" }, { status: 404 }); + } + return NextResponse.json({ template }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + const { name, description, metadata, activeVersion } = body; + + // Set active version if requested + if (activeVersion !== undefined) { + await templateRepository.setActiveVersion({ + templateId: id, + version: activeVersion, + }); + } + + const template = await templateRepository.update({ + id, + ...(name !== undefined && { name }), + ...(description !== undefined && { description }), + ...(metadata !== undefined && { metadata }), + }); + + return NextResponse.json({ template }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + + // If the template is referenced anywhere, reject hard delete and + // tell the caller to hide instead. + const refs = await templateRepository.getReferenceCount({ id }); + if (refs.insertCount > 0 || refs.locationCount > 0) { + return NextResponse.json( + { + error: "Template is referenced and cannot be deleted. Hide it instead.", + references: refs, + }, + { status: 409 } + ); + } + + await templateRepository.remove({ id }); + return NextResponse.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/templates/[id]/stats/route.ts b/web/app/api/templates/[id]/stats/route.ts new file mode 100644 index 0000000..d9828f6 --- /dev/null +++ b/web/app/api/templates/[id]/stats/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { templateRepository } from "@/repositories/templateRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const template = await templateRepository.findById({ id }); + if (!template) { + return NextResponse.json({ error: "Template not found" }, { status: 404 }); + } + const refs = await templateRepository.getReferenceCount({ id }); + return NextResponse.json({ + stats: { + ...refs, + isHidden: template.isHidden, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/templates/[id]/versions/route.ts b/web/app/api/templates/[id]/versions/route.ts new file mode 100644 index 0000000..e38d9c1 --- /dev/null +++ b/web/app/api/templates/[id]/versions/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { templateRepository } from "@/repositories/templateRepository"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + + const template = await templateRepository.findById({ id }); + if (!template) { + return NextResponse.json({ error: "Template not found" }, { status: 404 }); + } + + const versions = await templateRepository.listVersions({ templateId: id }); + return NextResponse.json({ versions }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + const body = await request.json(); + + const version = await templateRepository.publishVersion({ + templateId: id, + ...body, + }); + + return NextResponse.json({ version }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/templates/route.ts b/web/app/api/templates/route.ts new file mode 100644 index 0000000..18ef0bf --- /dev/null +++ b/web/app/api/templates/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { templateRepository } from "@/repositories/templateRepository"; + +export async function GET(request: NextRequest) { + try { + const includeHidden = + request.nextUrl.searchParams.get("includeHidden") === "true"; + const templates = await templateRepository.listWithCurrentVersion({ + includeHidden, + }); + return NextResponse.json({ templates }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, description, metadata, ...versionFields } = body; + + if (!name) { + return NextResponse.json({ error: "name is required" }, { status: 400 }); + } + + const template = await templateRepository.create({ + name, + description, + metadata, + ...versionFields, + }); + + return NextResponse.json({ template }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/web/app/api/transactions/route.ts b/web/app/api/transactions/route.ts new file mode 100644 index 0000000..3db9138 --- /dev/null +++ b/web/app/api/transactions/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { transactionRepository } from "@/repositories/transactionRepository"; + +export async function GET(request: NextRequest) { + try { + const limitParam = request.nextUrl.searchParams.get("limit"); + const limit = limitParam ? parseInt(limitParam, 10) : 50; + const transactions = await transactionRepository.listRecent({ limit }); + return NextResponse.json({ transactions }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Unexpected error" }, + { status: 500 }, + ); + } +} diff --git a/web/app/components/Sidebar.tsx b/web/app/components/Sidebar.tsx new file mode 100644 index 0000000..520b7b6 --- /dev/null +++ b/web/app/components/Sidebar.tsx @@ -0,0 +1,257 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +type NavItem = { + href: string; + title: string; + icon: React.ReactNode; + children?: Array<{ href: string; title: string }>; +}; + +type NavSection = { + label: string | null; + items: NavItem[]; +}; + +const navSections: NavSection[] = [ + { + label: null, // primary workflow — no heading + items: [ + { + href: "/modules", + title: "Modules", + icon: ( + + + + + + + ), + }, + { + href: "/inserts", + title: "Inserts", + icon: ( + + + + + ), + }, + { + href: "/items", + title: "Items", + icon: ( + + + + ), + }, + { + href: "/activity", + title: "Activity", + icon: ( + + + + + ), + }, + ], + }, + { + label: "Admin", + items: [ + { + href: "/modules/new", + title: "New Module", + icon: ( + + + + + + + ), + }, + { + href: "/templates", + title: "Templates", + icon: ( + + + + + ), + }, + { + href: "/taxonomy/aspects", + title: "Taxonomy", + icon: ( + + + + ), + children: [ + { href: "/taxonomy/aspects", title: "Aspects" }, + { href: "/taxonomy/parameters", title: "Parameters" }, + { href: "/taxonomy/standards", title: "Standards" }, + { href: "/taxonomy/categories", title: "Categories" }, + { href: "/taxonomy/audit", title: "Audit" }, + ], + }, + { + href: "/tour", + title: "Tour", + icon: ( + + + + + ), + }, + ], + }, +]; + +const STORAGE_KEY = "wheretf.sidebar.expanded"; + +export default function Sidebar() { + const pathname = usePathname(); + const [expanded, setExpanded] = useState(true); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored != null) setExpanded(stored === "1"); + setHydrated(true); + }, []); + + useEffect(() => { + if (hydrated) localStorage.setItem(STORAGE_KEY, expanded ? "1" : "0"); + }, [expanded, hydrated]); + + // Keyboard shortcut: Ctrl/Cmd + \ + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "\\") { + e.preventDefault(); + setExpanded((v) => !v); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + const width = expanded ? "w-48" : "w-14"; + + return ( + + ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..e3611a8 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,5 @@ +@import "tailwindcss"; + +@theme { + --color-accent: #ff6600; +} diff --git a/web/app/inserts/new/page.tsx b/web/app/inserts/new/page.tsx new file mode 100644 index 0000000..d8de9e8 --- /dev/null +++ b/web/app/inserts/new/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +interface TemplateOption { + id: string; + name: string; + description: string | null; + currentVersionData: { + id: string; + isParametric: boolean; + rows: number | null; + columns: number | null; + minRows: number | null; + maxRows: number | null; + minColumns: number | null; + maxColumns: number | null; + } | null; +} + +export default function NewInsertPage() { + const router = useRouter(); + const [templates, setTemplates] = useState([]); + const [templateId, setTemplateId] = useState(""); + const [name, setName] = useState(""); + const [rows, setRows] = useState(""); + const [columns, setColumns] = useState(""); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchTemplates = useCallback(async () => { + try { + const res = await fetch("/api/templates"); + const data = await res.json(); + setTemplates(data.templates ?? []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const selected = templates.find((t) => t.id === templateId); + const ver = selected?.currentVersionData ?? null; + const isParametric = !!ver?.isParametric; + + // Default dimensions when template picked + useEffect(() => { + if (!ver) { + setRows(""); + setColumns(""); + return; + } + if (ver.isParametric) { + setRows(ver.minRows ?? 1); + setColumns(ver.minColumns ?? 1); + } else { + setRows(ver.rows ?? 1); + setColumns(ver.columns ?? 1); + } + }, [templateId, ver]); + + async function submit() { + if (!selected || !ver) return; + setSaving(true); + setError(null); + try { + const body: Record = { + templateId: selected.id, + templateVersionId: ver.id, + name: name.trim() || undefined, + }; + if (isParametric) { + body.rows = Number(rows) || 1; + body.columns = Number(columns) || 1; + } + const res = await fetch("/api/inserts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Failed to create insert"); + return; + } + const data = await res.json(); + router.push(`/inserts?selected=${data.insert.id}`); + } catch (err) { + console.error(err); + setError("Unexpected error"); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + ← Back to inserts + + +

+ New Insert +

+

+ Create a new physical insert. You can place it in a receptacle + later from the insert's detail page. +

+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + {isParametric && ver && ( +
+ + +
+ )} +
+
+ +
+ + Cancel + + +
+
+ ); +} diff --git a/web/app/inserts/page.tsx b/web/app/inserts/page.tsx new file mode 100644 index 0000000..0acd4d3 --- /dev/null +++ b/web/app/inserts/page.tsx @@ -0,0 +1,1707 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { CellGrid, type CellRow } from "@/app/_components/CellGrid"; + +interface Insert { + id: string; + uid: string | null; + name: string | null; + templateId: string | null; + templateVersionId: string | null; + locationId: string | null; + rows: number | null; + columns: number | null; + templateName: string | null; + interfaceType: string | null; + rowDividersFixed?: boolean; + columnDividersFixed?: boolean; + locationPath: string | null; + moduleName: string | null; +} + + +interface TemplateOption { + id: string; + name: string; +} + +function InsertsPageInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const templateId = searchParams.get("templateId") ?? ""; + const interfaceType = searchParams.get("interfaceType") ?? ""; + const placement = (searchParams.get("placement") ?? "all") as + | "all" + | "placed" + | "unplaced"; + const selectedId = searchParams.get("selected") ?? null; + + const [inserts, setInserts] = useState([]); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchInserts = useCallback(async () => { + const qs = new URLSearchParams(); + if (templateId) qs.set("templateId", templateId); + if (interfaceType) qs.set("interfaceType", interfaceType); + qs.set("placement", placement); + try { + const res = await fetch(`/api/inserts?${qs.toString()}`); + const data = await res.json(); + setInserts(data.inserts ?? []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }, [templateId, interfaceType, placement]); + + const fetchTemplates = useCallback(async () => { + try { + const res = await fetch("/api/templates"); + const data = await res.json(); + setTemplates( + (data.templates ?? []).map((t: { id: string; name: string }) => ({ + id: t.id, + name: t.name, + })) + ); + } catch (err) { + console.error(err); + } + }, []); + + useEffect(() => { + fetchInserts(); + }, [fetchInserts]); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + function setParam(key: string, value: string | null) { + const p = new URLSearchParams(searchParams.toString()); + if (value == null || value === "") p.delete(key); + else p.set(key, value); + const qs = p.toString(); + router.replace(`/inserts${qs ? `?${qs}` : ""}`); + } + + const interfaceOptions = useMemo(() => { + const set = new Set(); + for (const ins of inserts) if (ins.interfaceType) set.add(ins.interfaceType); + return [...set].sort(); + }, [inserts]); + + const selected = inserts.find((i) => i.id === selectedId) ?? null; + + return ( +
+ {/* Left — filters + list */} +
+
+
+

Inserts

+

+ Physical instances of templates +

+
+ + + New + +
+ +
+ + + + +
+ {(["all", "placed", "unplaced"] as const).map((v) => ( + + ))} +
+
+ +
+ {loading ? ( +
+ Loading… +
+ ) : inserts.length === 0 ? ( +
+ No inserts match the current filters. +
+ ) : ( +
    + {inserts.map((ins) => { + const isSelected = ins.id === selectedId; + const displayName = + ins.name ?? ins.templateName ?? "Insert"; + return ( +
  • + +
  • + ); + })} +
+ )} +
+
+ + {/* Right — detail */} + {selected ? ( + + ) : ( +
+ Select an insert to view its details. +
+ )} +
+ ); +} + +interface Receptacle { + id: string; + path: string; + label: string; + interfaceTypeAccepted: string | null; + moduleId: string; + moduleName: string | null; +} + +function InsertDetail({ + insert, + onChanged, +}: { + insert: Insert; + onChanged: () => void; +}) { + const [draftName, setDraftName] = useState(insert.name ?? ""); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + // Compatible receptacles (inline in Place section of View tab) + const [receptacles, setReceptacles] = useState([]); + const [receptaclesLoading, setReceptaclesLoading] = useState(false); + const [placing, setPlacing] = useState(false); + + // Cells (grid) — full type so we can show overrides + const [cells, setCells] = useState([]); + const [selectedCellId, setSelectedCellId] = useState(null); + // Multi-select for merge (sticky mode for discoverability) + const [multiSelect, setMultiSelect] = useState>(new Set()); + const [selectMode, setSelectMode] = useState(false); + + // Right pane mode: View/Assign (item + receptacle focused) vs + // Edit (definition focused — overrides, merge, divide). + const [panelMode, setPanelMode] = useState<"view" | "edit">("view"); + + // Assignments on this insert's cells + const [assignments, setAssignments] = useState< + Array<{ + id: string; + itemId: string; + locationId: string; + assignmentType: "placed" | "provisional"; + }> + >([]); + const [itemsById, setItemsById] = useState< + Map + >(new Map()); + + // Item picker + const [showItemPicker, setShowItemPicker] = useState(false); + const [itemSearchQuery, setItemSearchQuery] = useState(""); + const [itemSearchResults, setItemSearchResults] = useState< + Array<{ id: string; name: string; description: string | null }> + >([]); + + // Restrict override editor + const [editingRestrict, setEditingRestrict] = useState(false); + const [restrictDraft, setRestrictDraft] = useState({ + maxWidthMm: "", + maxHeightMm: "", + maxDepthMm: "", + reason: "", + }); + + // Divide editor + const [dividingOpen, setDividingOpen] = useState(false); + const [divideLabels, setDivideLabels] = useState(""); + const [divideOrientation, setDivideOrientation] = useState< + "lr" | "fb" | "tb" | "custom" + >("lr"); + const [divideCount, setDivideCount] = useState(2); + + const loadAll = useCallback(async () => { + try { + const locRes = await fetch(`/api/locations?insertId=${insert.id}`); + const locData = await locRes.json(); + const locs: CellRow[] = locData.locations ?? []; + setCells(locs); + + const leafIds = locs.map((l) => l.id); + if (leafIds.length > 0) { + const asns: typeof assignments = []; + await Promise.all( + leafIds.map(async (lid) => { + const r = await fetch(`/api/assignments?locationId=${lid}`); + const d = await r.json(); + for (const a of d.assignments ?? []) asns.push(a); + }) + ); + setAssignments(asns); + + const itemIds = [...new Set(asns.map((a) => a.itemId))]; + const itemMap = new Map< + string, + { id: string; name: string; description: string | null } + >(); + await Promise.all( + itemIds.map(async (iid) => { + const r = await fetch(`/api/items/${iid}`); + const d = await r.json(); + if (d.item) itemMap.set(d.item.id, d.item); + }) + ); + setItemsById(itemMap); + } else { + setAssignments([]); + setItemsById(new Map()); + } + } catch (err) { + console.error(err); + } + }, [insert.id]); + + useEffect(() => { + setDraftName(insert.name ?? ""); + setEditing(false); + setSelectedCellId(null); + setMultiSelect(new Set()); + setSelectMode(false); + setShowItemPicker(false); + setEditingRestrict(false); + setPanelMode("view"); + loadAll(); + }, [insert.id, insert.name, loadAll]); + + const loadReceptacles = useCallback(async () => { + setReceptaclesLoading(true); + try { + const res = await fetch( + `/api/inserts/${insert.id}/compatible-receptacles` + ); + const data = await res.json(); + setReceptacles(data.receptacles ?? []); + } catch (err) { + console.error(err); + } finally { + setReceptaclesLoading(false); + } + }, [insert.id]); + + // Keep the candidate list fresh as the user moves the insert around. + useEffect(() => { + loadReceptacles(); + }, [loadReceptacles, insert.locationId]); + + async function placeAt(locationId: string) { + setPlacing(true); + try { + const res = await fetch(`/api/inserts/${insert.id}/place`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locationId }), + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error || "Failed to place"); + return; + } + onChanged(); + await loadReceptacles(); + } catch (err) { + console.error(err); + } finally { + setPlacing(false); + } + } + + async function saveName() { + if (!editing) return; + setSaving(true); + try { + const res = await fetch(`/api/inserts/${insert.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: draftName.trim() || null }), + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error || "Failed to save"); + return; + } + setEditing(false); + onChanged(); + } catch (err) { + console.error(err); + } finally { + setSaving(false); + } + } + + async function unplace() { + if (!confirm("Remove this insert from its location?")) return; + try { + const res = await fetch(`/api/inserts/${insert.id}/place`, { + method: "DELETE", + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error || "Failed to unplace"); + return; + } + onChanged(); + } catch (err) { + console.error(err); + } + } + + const selectedCell = cells.find((c) => c.id === selectedCellId) ?? null; + const selectedAssignments = useMemo( + () => assignments.filter((a) => a.locationId === selectedCellId), + [assignments, selectedCellId] + ); + + function selectCell(id: string | null, additive = false) { + // Sticky select mode OR modifier-key additive click → multi-select + if ((selectMode || additive) && id) { + const next = new Set(multiSelect); + if (next.size === 0 && selectedCellId) next.add(selectedCellId); + if (next.has(id)) next.delete(id); + else next.add(id); + setMultiSelect(next); + setSelectedCellId(null); + setShowItemPicker(false); + setEditingRestrict(false); + return; + } + setMultiSelect(new Set()); + setSelectedCellId(id); + setShowItemPicker(false); + setEditingRestrict(false); + } + + async function mergeSelected() { + const ids = [...multiSelect]; + if (ids.length < 2) return; + // Origin = top-left-most cell + const picked = cells.filter((c) => ids.includes(c.id)); + picked.sort( + (a, b) => + (a.gridRow ?? 0) - (b.gridRow ?? 0) || + (a.gridColumn ?? 0) - (b.gridColumn ?? 0) + ); + const origin = picked[0]; + const aliases = picked.slice(1).map((c) => c.id); + try { + const r = await fetch(`/api/locations/merge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ originId: origin.id, aliasIds: aliases }), + }); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Merge failed"); + return; + } + setMultiSelect(new Set()); + setSelectedCellId(origin.id); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function submitDivide() { + if (!selectedCell) return; + const labels = divideLabels + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (labels.length < 2) { + alert("Provide at least two comma-separated labels."); + return; + } + try { + const r = await fetch( + `/api/locations/${selectedCell.id}/divide`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ labels, source: "ad_hoc" }), + } + ); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Divide failed"); + return; + } + setDividingOpen(false); + setDivideLabels(""); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function undivideAt(parentId: string) { + if ( + !confirm( + "Collapse this cell's subdivisions? Children will be removed." + ) + ) + return; + try { + const r = await fetch(`/api/locations/${parentId}/divide`, { + method: "DELETE", + }); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Undivide failed"); + return; + } + // If the currently selected cell was a child of this parent, it's + // gone now — select the former parent instead. + if (selectedCell && selectedCell.parentId === parentId) { + setSelectedCellId(parentId); + } + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function unmergeCell() { + if (!selectedCell) return; + try { + const r = await fetch( + `/api/locations/${selectedCell.id}/unmerge`, + { method: "POST" } + ); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Unmerge failed"); + return; + } + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function searchItems(q: string) { + setItemSearchQuery(q); + if (q.trim().length < 2) { + setItemSearchResults([]); + return; + } + try { + const r = await fetch(`/api/items?q=${encodeURIComponent(q.trim())}`); + const d = await r.json(); + setItemSearchResults(d.items ?? []); + } catch (err) { + console.error(err); + } + } + + async function assignItem(itemId: string) { + if (!selectedCellId) return; + try { + const r = await fetch(`/api/assignments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + itemId, + locationId: selectedCellId, + assignmentType: "placed", + }), + }); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Failed to assign"); + return; + } + setShowItemPicker(false); + setItemSearchQuery(""); + setItemSearchResults([]); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function unassignItem(assignmentId: string) { + try { + await fetch(`/api/assignments/${assignmentId}`, { method: "DELETE" }); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function disableCell() { + if (!selectedCell) return; + const reason = window.prompt( + "Disable this cell. Reason (optional):", + selectedCell.disableReason ?? "" + ); + if (reason === null) return; + try { + const r = await fetch( + `/api/locations/${selectedCell.id}/disable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: reason.trim() || undefined }), + } + ); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Failed"); + return; + } + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function enableCell() { + if (!selectedCell) return; + try { + await fetch(`/api/locations/${selectedCell.id}/disable`, { + method: "DELETE", + }); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + function openRestrict() { + if (!selectedCell) return; + setRestrictDraft({ + maxWidthMm: selectedCell.maxWidthMm ?? "", + maxHeightMm: selectedCell.maxHeightMm ?? "", + maxDepthMm: selectedCell.maxDepthMm ?? "", + reason: selectedCell.restrictReason ?? "", + }); + setEditingRestrict(true); + } + + async function saveRestrict() { + if (!selectedCell) return; + try { + const r = await fetch( + `/api/locations/${selectedCell.id}/restrict`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + maxWidthMm: restrictDraft.maxWidthMm.trim() || null, + maxHeightMm: restrictDraft.maxHeightMm.trim() || null, + maxDepthMm: restrictDraft.maxDepthMm.trim() || null, + reason: restrictDraft.reason.trim() || null, + }), + } + ); + if (!r.ok) { + const d = await r.json(); + alert(d.error || "Failed"); + return; + } + setEditingRestrict(false); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function clearRestrict() { + if (!selectedCell) return; + try { + await fetch(`/api/locations/${selectedCell.id}/restrict`, { + method: "DELETE", + }); + setEditingRestrict(false); + await loadAll(); + } catch (err) { + console.error(err); + } + } + + async function deleteInsert() { + if (!confirm("Delete this insert? This cannot be undone via the UI.")) return; + try { + const res = await fetch(`/api/inserts/${insert.id}`, { + method: "DELETE", + }); + if (!res.ok) { + const data = await res.json(); + alert(data.error || "Failed to delete"); + return; + } + onChanged(); + } catch (err) { + console.error(err); + } + } + + return ( +
+
+ {editing ? ( +
+ setDraftName(e.target.value)} + placeholder={insert.templateName ?? "Insert name"} + className="flex-1 text-lg font-semibold text-slate-100 bg-slate-800 border border-slate-600 rounded px-2 py-1 focus:border-accent focus:outline-none" + /> + + +
+ ) : ( +
+

+ {insert.name || insert.templateName || "Untitled insert"} +

+ +
+ )} +
+ + {/* Layout area: grid + optional cell panel */} +
+
+
+
+ Layout +
+
+ {cells.length === 0 ? ( +
+ No cells. Template has no grid, or insert was created without + one. +
+ ) : ( + <> +
+ +
+ + )} +
+ + {cells.length > 0 && ( +
+ {/* Tabs */} +
+ {(["view", "edit"] as const).map((m) => ( + + ))} +
+ + {/* Edit-mode select-for-merge toggle */} + {panelMode === "edit" && ( +
+ + {selectMode && ( +

+ Tip: hold Ctrl/Cmd and click to add or remove cells + without entering this mode. +

+ )} +
+ )} + + {/* Body */} + {panelMode === "edit" && multiSelect.size > 0 ? ( + /* Merge action panel — Edit mode only */ +
+ {(() => { + const picked = cells.filter((c) => + multiSelect.has(c.id) + ); + const labels = picked.map((c) => c.label).join(", "); + const anyDisabled = picked.some((c) => c.isDisabled); + return ( + <> +
+ {labels} +
+
+ {multiSelect.size}{" "} + {multiSelect.size === 1 ? "cell" : "cells"} + {!anyDisabled && multiSelect.size >= 2 + ? " — ready to merge" + : ""} +
+ {anyDisabled && ( +
+ Disabled cell in selection. Enable it first. +
+ )} +
+ + +
+ + ); + })()} +
+ ) : panelMode === "edit" && selectMode ? ( +
+ Click two or more cells to merge. +
+ ) : panelMode === "view" && !selectedCell ? ( + /* View tab, nothing selected — placement still useful */ + + ) : !selectedCell ? ( +
+ Pick a cell to rework it. +
+ ) : ( + <> + {panelMode === "view" && ( + + )} +
+
+ {selectedCell.label} +
+
+ + {/* Assignments — View tab only */} + {panelMode === "view" && ( +
+ {selectedAssignments.length === 0 ? ( +
+

+ No items assigned. +

+ {!selectedCell.isDisabled && ( + + )} +
+ ) : ( +
+

+ Assigned Items +

+ {selectedAssignments.map((a) => { + const item = itemsById.get(a.itemId); + return ( +
+
+
+
+ {item?.name ?? "Unknown item"} +
+ {item?.description && ( +
+ {item.description} +
+ )} +
+ + {a.assignmentType} + +
+ +
+ ); + })} + {!selectedCell.isDisabled && ( + + )} +
+ )} + + {/* Item picker */} + {showItemPicker && ( +
+ searchItems(e.target.value)} + placeholder="Search items…" + autoFocus + className="w-full px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-200 focus:border-accent focus:outline-none" + /> +
+ {itemSearchResults.length > 0 ? ( + itemSearchResults.map((it) => ( + + )) + ) : itemSearchQuery.trim().length >= 2 ? ( +

+ No items found. +

+ ) : ( +

+ Type 2+ chars to search. +

+ )} +
+ +
+ )} +
+ )} + + {/* Overrides — view: read-only summary; edit: full controls */} + {panelMode === "view" ? ( + (selectedCell.isDisabled || + selectedCell.maxWidthMm || + selectedCell.maxHeightMm || + selectedCell.maxDepthMm || + cells.some( + (c) => c.mergedIntoId === selectedCell.id + )) && ( +
+

+ Status +

+ {selectedCell.isDisabled && ( +
+ Disabled + {selectedCell.disableReason && + `: ${selectedCell.disableReason}`} +
+ )} + {(selectedCell.maxWidthMm || + selectedCell.maxHeightMm || + selectedCell.maxDepthMm) && ( +
+ Restricted:{" "} + {[ + selectedCell.maxWidthMm && + `W≤${selectedCell.maxWidthMm}mm`, + selectedCell.maxHeightMm && + `H≤${selectedCell.maxHeightMm}mm`, + selectedCell.maxDepthMm && + `D≤${selectedCell.maxDepthMm}mm`, + ] + .filter(Boolean) + .join(", ")} + {selectedCell.restrictReason && + ` — ${selectedCell.restrictReason}`} +
+ )} + {cells.some( + (c) => c.mergedIntoId === selectedCell.id + ) && ( +
+ Merged with{" "} + {cells + .filter((c) => c.mergedIntoId === selectedCell.id) + .map((c) => c.label) + .join(", ")} +
+ )} + +
+ ) + ) : ( +
+

+ Overrides +

+ + {cells.some((c) => c.mergedIntoId === selectedCell.id) && ( + + )} + + {/* Divide / Undivide — undivide applies to the parent + whether the user selected the parent itself or any + of its children */} + {cells.some((c) => c.parentId === selectedCell.id) ? ( + + ) : selectedCell.parentId && + cells.some((c) => c.id === selectedCell.parentId) ? ( + + ) : dividingOpen ? ( + { + setDividingOpen(false); + setDivideLabels(""); + setDivideOrientation("lr"); + setDivideCount(2); + }} + /> + ) : ( + + )} + + {selectedCell.isDisabled ? ( + + ) : ( + + )} + + {editingRestrict ? ( +
+
+ Clamp usable capacity (mm). Blank = no clamp. +
+
+ {(["maxWidthMm", "maxHeightMm", "maxDepthMm"] as const).map( + (k) => ( + + ) + )} +
+ + setRestrictDraft({ + ...restrictDraft, + reason: e.target.value, + }) + } + className="w-full px-2 py-1 bg-slate-900 border border-slate-600 rounded text-xs text-slate-200 focus:border-accent focus:outline-none placeholder:text-slate-600" + /> +
+ + + {(selectedCell.maxWidthMm || + selectedCell.maxHeightMm || + selectedCell.maxDepthMm) && ( + + )} +
+
+ ) : selectedCell.maxWidthMm || + selectedCell.maxHeightMm || + selectedCell.maxDepthMm ? ( +
+
+ Restricted:{" "} + {[ + selectedCell.maxWidthMm && + `W≤${selectedCell.maxWidthMm}mm`, + selectedCell.maxHeightMm && + `H≤${selectedCell.maxHeightMm}mm`, + selectedCell.maxDepthMm && + `D≤${selectedCell.maxDepthMm}mm`, + ] + .filter(Boolean) + .join(", ")} +
+ {selectedCell.restrictReason && ( +
+ {selectedCell.restrictReason} +
+ )} + +
+ ) : ( + + )} +
+ )} + + )} + + {/* Edit-tab footer: destructive Delete insert. + Always visible at the bottom of Edit tab regardless of + cell selection. GitHub-style red. */} + {panelMode === "edit" && ( +
+ +

+ Removes the insert and all its cells. Items become + unassigned. +

+
+ )} +
+ )} +
+ + {/* Bottom action bar removed — Place/Move/Unplace live in View tab, + Delete insert lives at the bottom of Edit tab. Compat-receptacle + picker is now inline in the View tab — no modal. */} +
+ ); +} + +/** + * IN-8: suggest subdivision labels based on orientation + count. + * Templates can declare their own subdivisionOptions in future; for + * now we use a small built-in set of presets plus a Custom mode. + */ +function suggestLabels( + orientation: "lr" | "fb" | "tb" | "custom", + count: number +): string { + const n = Math.max(2, Math.min(10, count)); + if (orientation === "custom") return ""; + if (n === 2) { + return orientation === "lr" + ? "left, right" + : orientation === "fb" + ? "front, rear" + : "top, bottom"; + } + if (n === 3 && orientation === "lr") return "left, center, right"; + if (n === 3 && orientation === "fb") return "front, middle, rear"; + if (n === 3 && orientation === "tb") return "top, middle, bottom"; + // 4+: number them along the chosen axis + return Array.from({ length: n }, (_, i) => String(i + 1)).join(", "); +} + +function DivideForm({ + orientation, + setOrientation, + count, + setCount, + labels, + setLabels, + onSubmit, + onCancel, +}: { + orientation: "lr" | "fb" | "tb" | "custom"; + setOrientation: (v: "lr" | "fb" | "tb" | "custom") => void; + count: number; + setCount: (v: number) => void; + labels: string; + setLabels: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; +}) { + // Whenever orientation or count changes, regenerate the suggested + // labels unless the user has switched to Custom. + useEffect(() => { + if (orientation === "custom") return; + setLabels(suggestLabels(orientation, count)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orientation, count]); + + // Seed initial labels when the form opens + useEffect(() => { + if (!labels) setLabels(suggestLabels(orientation, count)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ Split +
+ {( + [ + { v: "lr" as const, label: "← →" }, + { v: "fb" as const, label: "front/rear" }, + { v: "tb" as const, label: "top/bottom" }, + { v: "custom" as const, label: "custom" }, + ] + ).map((o) => ( + + ))} +
+
+ + +
+ + +
+
+ ); +} + +function PlacementSection({ + insert, + unplace, + receptacles, + receptaclesLoading, + placing, + onPlace, +}: { + insert: Insert; + unplace: () => void; + receptacles: Receptacle[]; + receptaclesLoading: boolean; + placing: boolean; + onPlace: (locationId: string) => void; +}) { + // Two-step confirm: click a candidate to select it, then press + // Place/Move to commit. Avoids accidental single-click moves. + const [pendingId, setPendingId] = useState(null); + useEffect(() => { + setPendingId(null); + }, [insert.id, insert.locationId]); + + const verb = insert.locationPath ? "Move" : "Place"; + + return ( +
+
+
+ Placement +
+ {insert.locationPath ? ( +
+ + {insert.locationPath} + + +
+ ) : ( +
Unplaced
+ )} +
+ + {/* Inline candidate list — compatible empty receptacles. */} +
+
+ {verb} to… + {insert.interfaceType && ( + <> + {" "} + (accepts{" "} + + {insert.interfaceType} + + ) + + )} +
+ {receptaclesLoading ? ( +
Loading…
+ ) : receptacles.length === 0 ? ( +
+ No compatible empty receptacles. +
+ ) : ( +
    + {receptacles.map((r) => { + const isPending = pendingId === r.id; + return ( +
  • + +
  • + ); + })} +
+ )} + {pendingId && ( +
+ + +
+ )} +
+
+ ); +} + +function ViewTabBody({ + insert, + unplace, + receptacles, + receptaclesLoading, + placing, + onPlace, +}: { + placementOnly?: boolean; + insert: Insert; + unplace: () => void; + receptacles: Receptacle[]; + receptaclesLoading: boolean; + placing: boolean; + onPlace: (locationId: string) => void; +}) { + return ( + <> + +
+ Pick a cell to peek inside. +
+ + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +export default function InsertsPage() { + return ( + + Loading… +
+ } + > + + + ); +} + diff --git a/web/app/items/CreateFromDesignationDialog.tsx b/web/app/items/CreateFromDesignationDialog.tsx new file mode 100644 index 0000000..0b70ba2 --- /dev/null +++ b/web/app/items/CreateFromDesignationDialog.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +export interface CreateFromDesignationDialogProps { + designationId: string; + designation: string; + standardId: string; + standardName: string; + onClose: () => void; + onCreated?: (itemId: string) => void; +} + +interface AspectLink { + aspectId: string; + aspectName: string; +} + +interface CategorySuggestion { + categoryId: string; + name: string; + icon: string | null; + color: string | null; + matched: number; + total: number; + score: number; +} + +interface CategoryRow { + id: string; + name: string; + icon: string | null; + color: string | null; +} + +export default function CreateFromDesignationDialog({ + designationId, + designation, + standardId, + standardName, + onClose, + onCreated, +}: CreateFromDesignationDialogProps) { + const router = useRouter(); + const [linkedAspects, setLinkedAspects] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [allCategories, setAllCategories] = useState([]); + const [showAll, setShowAll] = useState(false); + const [selectedCategoryId, setSelectedCategoryId] = useState( + null + ); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [similar, setSimilar] = useState< + { itemId: string; itemName: string }[] + >([]); + const [acknowledged, setAcknowledged] = useState(false); + + // Load everything the dialog needs. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [stdRes, catsRes] = await Promise.all([ + fetch(`/api/standards/${standardId}`), + fetch("/api/categories"), + ]); + const std = await stdRes.json(); + const cats = await catsRes.json(); + if (cancelled) return; + + const aspects: AspectLink[] = (std.aspects ?? []).map( + (a: { aspectId: string; aspectName: string }) => ({ + aspectId: a.aspectId, + aspectName: a.aspectName, + }) + ); + setLinkedAspects(aspects); + setAllCategories(cats.categories ?? []); + + // Fetch suggestions based on the standard + its linked aspects. + const params = new URLSearchParams(); + params.set("standardId", standardId); + for (const a of aspects) params.append("aspectId", a.aspectId); + const [sugRes, simRes] = await Promise.all([ + fetch(`/api/items/suggest-categories?${params.toString()}`), + fetch( + `/api/items/find-similar?standardId=${standardId}&designationId=${designationId}` + ), + ]); + const sug = await sugRes.json(); + const sim = await simRes.json(); + if (cancelled) return; + setSuggestions(sug.suggestions ?? []); + setSimilar( + (sim.candidates ?? []).map( + (c: { itemId: string; itemName: string }) => ({ + itemId: c.itemId, + itemName: c.itemName, + }) + ) + ); + } catch (err) { + if (!cancelled) + setError(err instanceof Error ? err.message : "Failed to load"); + } + })(); + return () => { + cancelled = true; + }; + }, [standardId]); + + // Auto-fill the name when category changes. + // Universal rule for now: "{designation} {category.name}". + // TODO: replace with AI-generated name once that layer exists. + const defaultName = useCallback(() => { + const cat = + allCategories.find((c) => c.id === selectedCategoryId)?.name ?? + suggestions.find((s) => s.categoryId === selectedCategoryId)?.name ?? + ""; + return cat ? `${designation} ${cat}` : designation; + }, [allCategories, suggestions, selectedCategoryId, designation]); + + useEffect(() => { + setName(defaultName()); + }, [defaultName]); + + async function handleCreate() { + if (!name.trim()) return; + setCreating(true); + setError(null); + try { + // 1. Create the item. + const createRes = await fetch("/api/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + const createData = await createRes.json(); + if (!createRes.ok) + throw new Error(createData.error || "Failed to create item"); + const itemId = createData.item.id; + + // 2. Apply standard + designation (auto-fill fires server-side). + await fetch(`/api/items/${itemId}/standards`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ standardId, designationId }), + }); + + // 3. Apply each linked aspect. + await Promise.all( + linkedAspects.map((a) => + fetch(`/api/items/${itemId}/aspects`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aspectId: a.aspectId }), + }) + ) + ); + + // 4. Apply category if chosen. + if (selectedCategoryId) { + await fetch(`/api/items/${itemId}/categories`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + categoryId: selectedCategoryId, + isPrimary: true, + }), + }); + } + + onCreated?.(itemId); + onClose(); + router.push(`/items?selected=${itemId}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setCreating(false); + } + } + + const suggestedIds = new Set(suggestions.map((s) => s.categoryId)); + const remainingCategories = allCategories.filter( + (c) => !suggestedIds.has(c.id) + ); + + return ( +
+
+ {/* Header */} +
+
+ New item from designation +
+
+ + {standardName} + + · + {designation} +
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {similar.length > 0 && ( +
+
+
+ + {similar.length} existing item + {similar.length === 1 ? "" : "s"} + {" "} + already use{similar.length === 1 ? "s" : ""} this + designation. Double-check before creating another. +
+ +
+
    + {similar.slice(0, 5).map((c) => ( +
  • {c.itemName}
  • + ))} + {similar.length > 5 && ( +
  • + + {similar.length - 5} more +
  • + )} +
+
+ )} + + {/* Aspects (informational) */} + {linkedAspects.length > 0 && ( +
+
+ Aspects applied automatically +
+
+ {linkedAspects.map((a) => ( + + {a.aspectName} + + ))} +
+
+ )} + + {/* Category suggestions */} +
+
+ Category +
+ {suggestions.length === 0 && !showAll && ( +

+ No suggestions (empty or non-overlapping catalog). Pick one + below. +

+ )} +
+ {suggestions.map((s) => { + const isSel = selectedCategoryId === s.categoryId; + return ( + + ); + })} + +
+ {showAll && ( +
+ {remainingCategories.map((c) => { + const isSel = selectedCategoryId === c.id; + return ( + + ); + })} + {remainingCategories.length === 0 && ( + + No other categories. + + )} +
+ )} +
+ + {/* Name */} +
+
+ Name +
+ setName(e.target.value)} + className="w-full px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm text-slate-100 focus:border-accent focus:outline-none" + placeholder={defaultName()} + /> +

+ Auto-filled from designation + category. Override freely. +

+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/web/app/items/CreateItemModal.tsx b/web/app/items/CreateItemModal.tsx new file mode 100644 index 0000000..2134e41 --- /dev/null +++ b/web/app/items/CreateItemModal.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface CategoryOption { + id: string; + name: string; + icon: string | null; + color: string | null; +} + +export default function CreateItemModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated: (itemId: string) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [categories, setCategories] = useState([]); + const [selectedCategoryIds, setSelectedCategoryIds] = useState>( + new Set() + ); + const [primaryCategoryId, setPrimaryCategoryId] = useState( + null + ); + const [submitting, setSubmitting] = useState(false); + const nameRef = useRef(null); + + // Fetch categories when modal opens + useEffect(() => { + if (!open) return; + setName(""); + setDescription(""); + setSelectedCategoryIds(new Set()); + setPrimaryCategoryId(null); + setSubmitting(false); + + fetch("/api/categories") + .then((r) => r.json()) + .then((data) => setCategories(data.categories || [])) + .catch(console.error); + + // Focus name input after render + requestAnimationFrame(() => nameRef.current?.focus()); + }, [open]); + + const toggleCategory = (id: string) => { + setSelectedCategoryIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + if (primaryCategoryId === id) setPrimaryCategoryId(null); + } else { + next.add(id); + // Auto-set primary if it's the first selection + if (next.size === 1) setPrimaryCategoryId(id); + } + return next; + }); + }; + + const handleSubmit = async () => { + if (!name.trim()) return; + setSubmitting(true); + + try { + // Create item + const res = await fetch("/api/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + description: description.trim() || undefined, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const { item } = await res.json(); + + // Apply categories + for (const categoryId of selectedCategoryIds) { + await fetch(`/api/items/${item.id}/categories`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + categoryId, + isPrimary: categoryId === primaryCategoryId, + }), + }); + } + + onCreated(item.id); + } catch (err) { + console.error("Failed to create item:", err); + setSubmitting(false); + } + }; + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

New Item

+ +
+ +
+ {/* Name */} +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim()) handleSubmit(); + if (e.key === "Escape") onClose(); + }} + placeholder="e.g. M3x10 Socket Head Cap Screw" + className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded text-sm text-slate-100 outline-none focus:border-accent placeholder:text-slate-600 transition-colors" + /> +
+ + {/* Description */} +
+ + setDescription(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim()) handleSubmit(); + if (e.key === "Escape") onClose(); + }} + placeholder="Optional — brief description" + className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded text-sm text-slate-100 outline-none focus:border-accent placeholder:text-slate-600 transition-colors" + /> +
+ + {/* Categories */} +
+ + {categories.length === 0 ? ( +

+ No categories defined +

+ ) : ( +
+ {categories.map((cat) => { + const selected = selectedCategoryIds.has(cat.id); + const isPrimary = primaryCategoryId === cat.id; + return ( + + ); + })} +
+ )} + {selectedCategoryIds.size > 1 && ( +

+ Double-click a selected category to set it as primary +

+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/web/app/items/FilterPanel.tsx b/web/app/items/FilterPanel.tsx new file mode 100644 index 0000000..da623e8 --- /dev/null +++ b/web/app/items/FilterPanel.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useRef } from "react"; + +interface FilterPill { + parameterDefinitionId: string; + parameterName: string; + value: unknown; +} + +interface CategoryCount { + id: string; + name: string; + icon: string | null; + color: string | null; + count: number; +} + +export default function FilterPanel({ + query, + onQueryChange, + filterPills, + onRemoveFilter, + categoryCounts, + activeCategoryId, + onCategoryClick, +}: { + query: string; + onQueryChange: (q: string) => void; + filterPills: FilterPill[]; + onRemoveFilter: (parameterDefinitionId: string) => void; + categoryCounts: CategoryCount[]; + activeCategoryId: string; + onCategoryClick: (categoryId: string) => void; +}) { + const inputRef = useRef(null); + const debounceRef = useRef>(null); + + const handleSearchInput = (e: React.ChangeEvent) => { + const value = e.target.value; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + onQueryChange(value); + }, 300); + }; + + return ( +
+ {/* Search */} +
+
+ + + + + + {query && ( + + )} +
+
+ + {/* Filter Pills */} + {filterPills.length > 0 && ( +
+ {filterPills.map((pill) => ( + + + {pill.parameterName || pill.parameterDefinitionId}: + + {String(pill.value)} + + + ))} +
+ )} + + {/* Categories */} +
+

+ Categories +

+ {categoryCounts.length === 0 ? ( +

No categories

+ ) : ( +
+ {categoryCounts.map((cat) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/web/app/items/GenerateSetDialog.tsx b/web/app/items/GenerateSetDialog.tsx new file mode 100644 index 0000000..d38f746 --- /dev/null +++ b/web/app/items/GenerateSetDialog.tsx @@ -0,0 +1,711 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; + +export interface GenerateSetDialogProps { + designationId: string; + designation: string; + standardId: string; + standardName: string; + onClose: () => void; + onCreated?: (itemIds: string[]) => void; +} + +interface AspectLink { + aspectId: string; + aspectName: string; +} + +interface CategorySuggestion { + categoryId: string; + name: string; + icon: string | null; + color: string | null; + matched: number; + total: number; + score: number; +} + +interface CategoryRow { + id: string; + name: string; + icon: string | null; + color: string | null; +} + +interface ParameterOption { + parameterDefinitionId: string; + name: string; + dataType: string; + unit: string | null; + constraints: { enumValues?: string[] } | null; +} + +type RangeMode = "numeric" | "enum" | "list"; + +interface PreviewRow { + included: boolean; + value: string | number | boolean; + display: string; + name: string; + duplicateOf?: string; // existing item name +} + +interface SimilarCandidate { + itemId: string; + itemName: string; + paramValues: Record; +} + +export default function GenerateSetDialog({ + designationId, + designation, + standardId, + standardName, + onClose, + onCreated, +}: GenerateSetDialogProps) { + const router = useRouter(); + const [linkedAspects, setLinkedAspects] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [allCategories, setAllCategories] = useState([]); + const [showAll, setShowAll] = useState(false); + const [selectedCategoryId, setSelectedCategoryId] = useState( + null + ); + + const [paramOptions, setParamOptions] = useState([]); + const [variableParamId, setVariableParamId] = useState(null); + + const [rangeMode, setRangeMode] = useState("numeric"); + const [rangeFrom, setRangeFrom] = useState(""); + const [rangeTo, setRangeTo] = useState(""); + const [rangeStep, setRangeStep] = useState("1"); + const [enumChecked, setEnumChecked] = useState>({}); + const [listText, setListText] = useState(""); + + const [nameTemplate, setNameTemplate] = useState(""); + const [preview, setPreview] = useState([]); + + const [similar, setSimilar] = useState([]); + const [creating, setCreating] = useState(false); + const [progress, setProgress] = useState<{ done: number; total: number }>({ + done: 0, + total: 0, + }); + const [error, setError] = useState(null); + const [failedRows, setFailedRows] = useState([]); + + // Load aspects, parameters, categories, suggestions + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [stdRes, catsRes] = await Promise.all([ + fetch(`/api/standards/${standardId}`), + fetch("/api/categories"), + ]); + const std = await stdRes.json(); + const cats = await catsRes.json(); + if (cancelled) return; + + const aspects: AspectLink[] = (std.aspects ?? []).map( + (a: { aspectId: string; aspectName: string }) => ({ + aspectId: a.aspectId, + aspectName: a.aspectName, + }) + ); + setLinkedAspects(aspects); + setAllCategories(cats.categories ?? []); + + const sugParams = new URLSearchParams(); + sugParams.set("standardId", standardId); + for (const a of aspects) sugParams.append("aspectId", a.aspectId); + const [sugRes, simRes] = await Promise.all([ + fetch(`/api/items/suggest-categories?${sugParams.toString()}`), + fetch( + `/api/items/find-similar?standardId=${standardId}&designationId=${designationId}` + ), + ]); + const sug = await sugRes.json(); + const sim = await simRes.json(); + if (cancelled) return; + setSuggestions(sug.suggestions ?? []); + setSimilar(sim.candidates ?? []); + + // Aggregate all params across linked aspects + const paramsList: ParameterOption[] = []; + const seen = new Set(); + for (const a of aspects) { + const r = await fetch(`/api/aspects/${a.aspectId}/parameters`); + const d = await r.json(); + for (const p of d.parameters ?? []) { + if (seen.has(p.parameterDefinitionId)) continue; + seen.add(p.parameterDefinitionId); + paramsList.push({ + parameterDefinitionId: p.parameterDefinitionId, + name: p.parameterName, + dataType: p.dataType, + unit: p.unit, + constraints: p.constraints ?? null, + }); + } + } + if (cancelled) return; + setParamOptions(paramsList); + // Pick first param of type numeric as default variable if available + const numericFirst = paramsList.find((p) => p.dataType === "numeric"); + if (numericFirst) + setVariableParamId(numericFirst.parameterDefinitionId); + else if (paramsList.length > 0) + setVariableParamId(paramsList[0].parameterDefinitionId); + } catch (err) { + if (!cancelled) + setError(err instanceof Error ? err.message : "Load failed"); + } + })(); + return () => { + cancelled = true; + }; + }, [standardId]); + + const variableParam = useMemo( + () => + paramOptions.find((p) => p.parameterDefinitionId === variableParamId) ?? + null, + [paramOptions, variableParamId] + ); + + // When variable param changes, re-pick a reasonable range mode + useEffect(() => { + if (!variableParam) return; + if (variableParam.dataType === "enum") setRangeMode("enum"); + else if (variableParam.dataType === "numeric") setRangeMode("numeric"); + else setRangeMode("list"); + setEnumChecked({}); + setListText(""); + }, [variableParam]); + + // Default name template + useEffect(() => { + if (nameTemplate) return; + const cat = + allCategories.find((c) => c.id === selectedCategoryId)?.name ?? + suggestions.find((s) => s.categoryId === selectedCategoryId)?.name ?? + ""; + const varPart = "{var}"; + const unit = variableParam?.unit ? "{unit}" : ""; + const tpl = `${designation}${cat ? " " + cat : ""} ${varPart}${unit}`.trim(); + setNameTemplate(tpl); + }, [ + designation, + selectedCategoryId, + variableParam, + allCategories, + suggestions, + nameTemplate, + ]); + + const buildValues = useCallback((): Array => { + if (!variableParam) return []; + if (rangeMode === "numeric") { + const from = Number(rangeFrom); + const to = Number(rangeTo); + const step = Number(rangeStep) || 1; + if (!Number.isFinite(from) || !Number.isFinite(to)) return []; + const out: number[] = []; + if (step === 0) return []; + if (from <= to && step > 0) { + for (let v = from; v <= to + 1e-9; v += step) { + out.push(Number(v.toFixed(6))); + if (out.length > 500) break; + } + } else if (from >= to && step < 0) { + for (let v = from; v >= to - 1e-9; v += step) { + out.push(Number(v.toFixed(6))); + if (out.length > 500) break; + } + } + return out; + } + if (rangeMode === "enum") { + return Object.entries(enumChecked) + .filter(([, v]) => v) + .map(([k]) => k); + } + if (rangeMode === "list") { + return listText + .split(/[,\n]/) + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => { + if (variableParam.dataType === "numeric") { + const n = Number(s); + return Number.isFinite(n) ? n : s; + } + if (variableParam.dataType === "boolean") return s === "true"; + return s; + }); + } + return []; + }, [rangeMode, rangeFrom, rangeTo, rangeStep, enumChecked, listText, variableParam]); + + // Rebuild preview whenever inputs change + useEffect(() => { + const values = buildValues(); + setPreview( + values.map((v) => { + const dup = variableParam + ? similar.find((s) => { + const existing = s.paramValues[variableParam.parameterDefinitionId]; + return existing !== undefined && String(existing) === String(v); + }) + : undefined; + return { + included: !dup, + value: v, + display: + typeof v === "number" && !Number.isInteger(v) + ? v.toString() + : String(v), + name: substituteTemplate(nameTemplate, v, variableParam?.unit ?? ""), + duplicateOf: dup?.itemName, + }; + }) + ); + }, [buildValues, nameTemplate, variableParam, similar]); + + function substituteTemplate( + tpl: string, + v: string | number | boolean, + unit: string + ) { + return tpl + .replace(/\{var\}/g, String(v)) + .replace(/\{unit\}/g, unit) + .trim(); + } + + function togglePreview(i: number) { + setPreview((prev) => prev.map((r, idx) => (idx === i ? { ...r, included: !r.included } : r))); + } + + async function runCreate() { + const rows = preview + .map((r, i) => ({ r, i })) + .filter((x) => x.r.included); + if (rows.length === 0 || !variableParam) return; + setCreating(true); + setError(null); + setFailedRows([]); + setProgress({ done: 0, total: rows.length }); + + const newIds: string[] = []; + const failed: number[] = []; + + for (const { r, i } of rows) { + try { + const createRes = await fetch("/api/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: r.name }), + }); + const cData = await createRes.json(); + if (!createRes.ok) + throw new Error(cData.error || "create failed"); + const itemId = cData.item.id; + + await fetch(`/api/items/${itemId}/standards`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ standardId, designationId }), + }); + await Promise.all( + linkedAspects.map((a) => + fetch(`/api/items/${itemId}/aspects`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aspectId: a.aspectId }), + }) + ) + ); + if (selectedCategoryId) { + await fetch(`/api/items/${itemId}/categories`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + categoryId: selectedCategoryId, + isPrimary: true, + }), + }); + } + // Override variable parameter on the item + await fetch(`/api/items/${itemId}/parameters`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parameterDefinitionId: variableParam.parameterDefinitionId, + value: r.value, + }), + }); + newIds.push(itemId); + } catch (err) { + console.error(err); + failed.push(i); + } finally { + setProgress((p) => ({ ...p, done: p.done + 1 })); + } + } + + setCreating(false); + setFailedRows(failed); + if (newIds.length > 0) { + onCreated?.(newIds); + } + if (failed.length === 0) { + onClose(); + router.push("/items"); + } else { + setError( + `${failed.length} row(s) failed. The failed rows stay checked; resolve and retry.` + ); + } + } + + const suggestedIds = new Set(suggestions.map((s) => s.categoryId)); + const remainingCategories = allCategories.filter( + (c) => !suggestedIds.has(c.id) + ); + const includedCount = preview.filter((r) => r.included).length; + + return ( +
+
+ {/* Header */} +
+
+ Generate a set of items +
+
+ + {standardName} + + · + {designation} +
+
+ + {/* Body */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Aspects (informational) */} + {linkedAspects.length > 0 && ( +
+
+ Applied to each item +
+
+ {linkedAspects.map((a) => ( + + {a.aspectName} + + ))} +
+
+ )} + + {/* Category */} +
+
+ Category +
+
+ {suggestions.map((s) => { + const isSel = selectedCategoryId === s.categoryId; + return ( + + ); + })} + +
+ {showAll && ( +
+ {remainingCategories.map((c) => { + const isSel = selectedCategoryId === c.id; + return ( + + ); + })} +
+ )} +
+ + {/* Variable parameter */} +
+
+ Varying parameter +
+ + {paramOptions.length === 0 && ( +

+ No parameters on linked aspects. Link aspects + parameters + first. +

+ )} +
+ + {/* Range input */} + {variableParam && ( +
+
+ Values +
+
+ {(["numeric", "enum", "list"] as const).map((m) => ( + + ))} +
+ {rangeMode === "numeric" && ( +
+ + + +
+ )} + {rangeMode === "enum" && ( +
+ {(variableParam.constraints?.enumValues ?? []).map((v) => ( + + ))} + {(variableParam.constraints?.enumValues ?? []).length === + 0 && ( + + Parameter has no enum values defined. + + )} +
+ )} + {rangeMode === "list" && ( +