Compare commits

...
33 Commits
Author SHA1 Message Date
theis.gaedigk be2e250ba1 refactor: remove dpracted useStore 2026-09-02 17:36:00 +02:00
theis.gaedigk 6f3759d48d feat: add confirmation modal when deleting a storage 2026-09-02 17:30:43 +02:00
theis.gaedigk 80fb79dedd fix: minor design bug 2026-09-01 00:39:55 +02:00
theis.gaedigk d6603577a2 refactor: update ui and make it modern 2026-09-01 00:28:58 +02:00
theis.gaedigk ef79a07a55 fix: update picture with new functions displayed 2026-07-25 22:09:28 +02:00
theis.gaedigk 05ea471351 fix: mock data bug 2026-07-25 22:09:10 +02:00
theis.gaedigk 9df8570e5a refactor: update imports and delete "React." usages 2026-07-16 09:58:37 +02:00
theis.gaedigk b5a4f39c6f fix: bg color when hovered in dark mode 2026-07-12 19:12:24 +02:00
theis.gaedigk 262c2b665d docs: adjust docs 2026-07-10 13:31:48 +02:00
theis.gaedigk bde647aa08 refactor: delete console logs and unused node modules 2026-07-10 11:54:12 +02:00
theis.gaedigk e2a7d2e560 refactor: add _authed.tsx guard that checks the protected routes if the user is logged in 2026-07-10 11:53:07 +02:00
theis.gaedigk 99246e3e3d refactor: add new logout function with better info box in the login card 2026-07-10 11:18:25 +02:00
theis.gaedigk 3153076d72 refactor: outsource auth function in all routes 2026-07-10 11:17:31 +02:00
theis.gaedigk ef5fb3e1da docs: edit installation documentation 2026-07-10 08:13:00 +02:00
theis.gaedigk aa87442557 fix: import statement from icon 2026-07-09 15:29:14 +02:00
theis.gaedigk 580db6afe9 feat: implement dark mode and add custom color palette (theme) 2026-07-09 14:27:43 +02:00
theis.gaedigk bbca5a36cc fix: button alignment in settings page 2026-07-09 13:22:00 +02:00
theis.gaedigk 0d4a048d16 refatcor: remove old tostafiy npm package 2026-07-09 13:21:10 +02:00
theis.gaedigk 57fe6dc9ac feat: Add custom 404 not found page 2026-07-09 13:19:41 +02:00
theis.gaedigk ef905fb30b refactor: add custom alert component and remove all other Alert standard components in all components 2026-07-09 11:23:41 +02:00
theis.gaedigk 925cb73c47 fix: error in documentation 2026-07-08 19:02:09 +02:00
theis.gaedigk 99a1097053 fix: cannot build prod and delete unused file 2026-07-08 18:42:54 +02:00
theis.gaedigk 73dd672fdb fix: cannot build prod 2026-07-08 18:25:36 +02:00
theis.gaedigk 92b5fd92bc fix: cannot build prod 2026-07-08 15:31:32 +02:00
theis.gaedigkandCopilot d486655f54 Fix ES module/CommonJS mismatch in shared package
- Rename CommonJS build output from .js to .cjs extensions
- Add .js extensions to ESM imports (required by Node.js)
- Add .cjs extensions to CommonJS requires
- Add post-build scripts for both ESM and CommonJS builds
- Update package.json exports to reference .cjs files for require()

Fixes: ReferenceError: exports is not defined in ES module scope

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-08 15:23:06 +02:00
theis.gaedigk 018a7aefc7 refactor: change password validation and add more success alerts 2026-07-08 15:10:53 +02:00
theis.gaedigk 6be34c93aa refactor: edit tailwindcss classes and change code formatting, therefore added .prettierrc file
fix: frontend bug where the passwords won't be checked when the password is changed
2026-07-08 14:56:43 +02:00
theis.gaedigk 9c50c3f300 fix: docker container are starting --- update package-lock.json 2026-07-08 13:42:35 +02:00
theis.gaedigk 3a5c16031a fix: docker container are starting 2026-07-08 13:37:21 +02:00
theis.gaedigk c6bba8a40c fix: frontend error codes and translations 2026-07-08 11:57:38 +02:00
theis.gaedigk 87bed4e1c7 fix: backend won't start 2026-07-08 11:31:38 +02:00
theis.gaedigk 84fa2e0ab0 refactor: change backend to typescript and update types and variables accordingly 2026-07-08 10:54:07 +02:00
theis.gaedigk 36ec8da1a0 Fix: cast DB row to AuthTokenPayload in findUser\n\nCast database RowDataPacket to AuthTokenPayload so generateToken receives the correct type.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-07-08 10:27:21 +02:00
98 changed files with 7239 additions and 9750 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 912 KiB

+4
View File
@@ -19,9 +19,11 @@
.LSOverride .LSOverride
node_modules node_modules
tsconfig.tsbuildinfo
# Icon must end with two \r # Icon must end with two \r
.claude
# Thumbnails # Thumbnails
._* ._*
@@ -44,6 +46,8 @@ Temporary Items
ToDo.txt ToDo.txt
.idea .idea
dist
index.ts
.env .env
.docker/volumes .docker/volumes
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}
+34 -19
View File
@@ -14,7 +14,8 @@
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=flat) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=flat)
![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff&style=flat) ![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff&style=flat)
The problem solver for every household in the world: **STOCKHOME**! Have you ever spent too long searching for things in your freezer or pantry? If so, this open-source project is just the right thing for your home server! The problem solver for every household in the world: **STOCKHOME**! Have you ever spent too long searching for things in
your freezer or pantry? If so, this open-source project is just the right thing for your home server!
<p align="center"> <p align="center">
<img src="./.github/assets/Arc_Inventory.low.jpg" width="802" alt="Inventory Screenshot" /> <img src="./.github/assets/Arc_Inventory.low.jpg" width="802" alt="Inventory Screenshot" />
@@ -51,7 +52,8 @@ Clone this repository by running:
git clone --branch v0.1 --single-branch https://github.com/theis-js/stockhome.git git clone --branch v0.1 --single-branch https://github.com/theis-js/stockhome.git
``` ```
You can replace `v0.1` with the latest release tag to get the latest version of the stack. You can also use `dev` to get the latest development version, but keep in mind that it may contain bugs and errors. You can replace `v0.1` with any other release tag to get your desired version of the repo. You can also use `dev` to get
the latest development version, but keep in mind that it may contain bugs and errors.
View the available version tags here: [Releases](https://github.com/theis-js/stockhome/tags) View the available version tags here: [Releases](https://github.com/theis-js/stockhome/tags)
@@ -64,16 +66,18 @@ In the root directory of this repository create an `.env` file and enter the fol
```txt ```txt
MYSQL_ROOT_PASSWORD= MYSQL_ROOT_PASSWORD=
AUTH_SIGNATURE= AUTH_SIGNATURE=
BACKEND_HOST=localhost BACKEND_HOST=stockhome-backend
``` ```
Make sure that you have set an secure root password and a secure signature. Make sure that you have set a secure root password and a secure signature.
> **Note** These three values cannot contain special characters. > **Note** These three values cannot contain special characters.
The `BACKEND_HOST` can be set to the IP address of your server or to `localhost` if you are running the app on your local machine. The `BACKEND_HOST` can be set to the IP address of your server or to `localhost` if you are running the app on your
local machine.
> **Change Docker Config:** If you want to run the stack for example in a docker network or want to change something at the docker config, visit the [Docker docs](./.github/docs/docker/README.md) for this project. > **Change Docker Config:** If you want to run the stack for example in a docker network or want to change something at
> the docker config, visit the [Docker docs](./.github/docs/docker/README.md) for this project.
### 4. Start Stockhome ### 4. Start Stockhome
@@ -83,7 +87,7 @@ First, navigate into the root directory of this repository and run:
docker compose up -d --build docker compose up -d --build
``` ```
The database and all necessary services are started and initialised automatically. The database and all necessary services are started and initialized automatically.
### 5. First login ### 5. First login
@@ -94,31 +98,42 @@ Username: admin
Password: admin Password: admin
``` ```
The docker config exposes the frontend on port `80`. Therefore, you can access the web page either on
`http://localhost`, if you hosted it on your local machine, or under the IP-Adress or hostname of your server.
If port `80` is occupied, you can change the docker-compose. Therefore, take a look
at [Change Port Config](.github/docs/docker/README.md#ports).
_Keep in mind that you should change the password later in the settings page._ _Keep in mind that you should change the password later in the settings page._
> **Note** If errors occur get yourself some help in the [error code page](./.github/docs/error/README.md). > **Note** If errors occur get yourself some help in the [error code page](./.github/docs/error/README.md).
## Feedback and bug reports - Troubleshooting ## Feedback and bug reports - Troubleshooting
I'd be grateful if you could report any bugs as issues here in the repository! [Report a bug](https://github.com/theis-js/stockhome/issues/new/choose) I'd be grateful if you could report any bugs as issues here in the
repository! [Report a bug](https://github.com/theis-js/stockhome/issues/new/choose)
## Update ## Update
To update stockhome to the latest version, navigate into the root directory of this repository and run: To update Stockhome to the latest version, navigate into the root directory of this repository and run:
```shell
git fetch --all --tags && git checkout "$(git describe --tags "$(git rev-list --tags --max-count=1)")"
```
To rebuild the containers run:
```shell ```shell
git pull
latesttag=$(git describe --tags)
git checkout ${latesttag}
docker compose up -d --build docker compose up -d --build
``` ```
To update to a specific version, replace `latesttag` with the version tag you want to update to. You can view the available version tags here: [Releases](https://github.com/theis-js/stockhome/tags) To update/downgrade to a specific version, run the command below. You can replace `v0.1` with the tag/version you want.
You can find all Releases her: [Releases](https://github.com/theis-js/stockhome/tags).
**Example to downgrade/update to version `v0.1`:** **Example to downgrade/update to version `v0.1`:**
```shell ```shell
git pull git fetch --all
git checkout v0.1 git checkout v0.1
docker compose up -d --build docker compose up -d --build
``` ```
@@ -131,20 +146,20 @@ docker compose up -d --build
- Visual Studio Code (or any other IDE) - Visual Studio Code (or any other IDE)
- Cloned the repository - Cloned the repository
### Start backend and database ### Initialize repository dependencies
First, navigate into the root directory of this repository and run: First, navigate into the root directory of this repository and run:
```shell ```shell
docker compose -f docker-compose.dev.yml up -d --build npm install
``` ```
### Start frontend ### Start development environment
Navigate into the `frontend` directory and run: Stay in the root directory and run:
```shell ```shell
npm install npm run build:shared
npm run dev npm run dev
``` ```
+40 -5
View File
@@ -1,12 +1,47 @@
FROM node:20-alpine FROM node:22-alpine AS builder
WORKDIR /app
# Workspace manifests (npm workspaces need these present)
COPY package.json package-lock.json ./
COPY backend/package.json backend/
COPY frontend/package.json frontend/
COPY shared/package.json shared/
# Install all deps for the monorepo (includes devDeps for building)
RUN npm ci
# Copy sources required to build shared + backend
COPY shared/ shared/
COPY backend/ backend/
# Build shared (now produces dist/esm + dist/cjs + dist/types) and then backend
RUN npm run build --workspace=shared
RUN npm run build --workspace=backend
FROM node:22-alpine AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /backend WORKDIR /app
COPY package*.json ./ # Workspace manifests again (so npm can install prod deps for backend + shared)
COPY package.json package-lock.json ./
COPY backend/package.json backend/
COPY frontend/package.json frontend/
COPY shared/package.json shared/
# Install prod deps only
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY . . # Copy runtime artifacts
COPY --from=builder /app/backend/dist backend/dist
COPY --from=builder /app/backend/database.scheme.sql backend/database.scheme.sql
# Copy the full shared dist output (esm/cjs/types)
COPY --from=builder /app/shared/dist shared/dist
COPY shared/package.json shared/package.json
EXPOSE 8004 EXPOSE 8004
CMD ["npm", "start"] WORKDIR /app/backend
CMD ["node", "dist/server.js"]
+140 -104
View File
@@ -1,133 +1,169 @@
-- ============================================================= -- =============================================================
-- STOCKHOME MOCK DATA -- STOCKHOME MOCK DATA (EN)
-- Generates: 5 Storage Locations, ~100 Products -- Generates: 5 Storage Locations, 94 Products
--
-- Safe to re-run: storage locations are upserted by name, and
-- products reference the locations by looked-up UUID rather
-- than a hardcoded literal. This avoids the FK error 1452 that
-- occurs when a location with the same name already exists
-- (storage_locations.name is UNIQUE) with a different UUID.
--
-- Uncomment the DELETE below if you want a clean product table.
-- ============================================================= -- =============================================================
USE stockhome; USE stockhome;
-- Optional clean slate for products only (keeps user/app settings):
-- DELETE FROM products;
-- ============================================= -- =============================================
-- Storage Locations (5) -- Storage Locations (5)
-- ============================================= -- =============================================
INSERT INTO storage_locations (uuid, name, description) VALUES INSERT INTO storage_locations (uuid, name, description)
(UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), 'Main Freezer', 'Large chest freezer in the basement'), VALUES (UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), 'Main Freezer', 'Large chest freezer in the basement'),
(UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), 'Kitchen Freezer', 'Freezer compartment of the kitchen fridge'), (UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), 'Kitchen Freezer',
(UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), 'Pantry', 'Dry goods storage in the hallway'), 'Freezer compartment of the kitchen fridge'),
(UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), 'Kitchen Fridge', 'Main refrigerator in the kitchen'), (UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), 'Pantry', 'Dry goods storage in the hallway'),
(UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), 'Cellar Shelf', 'Cool storage shelves in the cellar'); (UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), 'Kitchen Fridge', 'Main refrigerator in the kitchen'),
(UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), 'Cellar Shelf', 'Cool storage shelves in the cellar')
ON DUPLICATE KEY UPDATE description = VALUES(description);
-- ============================================= -- =============================================
-- Products (~100) -- Resolve the actual UUIDs (works whether the rows were just
-- inserted or already existed with different UUIDs)
-- =============================================
SET @loc_main_freezer = (SELECT uuid
FROM storage_locations
WHERE name = 'Main Freezer');
SET @loc_kitchen_freezer = (SELECT uuid
FROM storage_locations
WHERE name = 'Kitchen Freezer');
SET @loc_pantry = (SELECT uuid
FROM storage_locations
WHERE name = 'Pantry');
SET @loc_kitchen_fridge = (SELECT uuid
FROM storage_locations
WHERE name = 'Kitchen Fridge');
SET @loc_cellar_shelf = (SELECT uuid
FROM storage_locations
WHERE name = 'Cellar Shelf');
-- =============================================
-- Products (94)
-- uuid, picture, created_at, updated_at and deleted rely on
-- their column defaults.
-- Self-frozen items have a bottling_date. -- Self-frozen items have a bottling_date.
-- Store-bought items have bottling_date = NULL. -- Store-bought items have bottling_date = NULL.
-- ============================================= -- =============================================
INSERT INTO products (uuid, name, description, price, amount, storage_location, expiry_date, bottling_date, picture, deleted) VALUES INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date)
VALUES
-- ── Self-frozen items (Main Freezer) ── -- ── Self-frozen items (Main Freezer) ──
(UUID_TO_BIN(UUID()), 'Strawberry Puree', 'Blended fresh strawberries', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-12-15', '2026-06-01', NULL, FALSE), ('Strawberry Puree', 'Blended fresh strawberries', NULL, 4, @loc_main_freezer, '2026-12-15', '2026-06-01'),
(UUID_TO_BIN(UUID()), 'Tomato Sauce', 'Homemade marinara sauce', NULL, 6, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-01-10', '2026-05-20', NULL, FALSE), ('Tomato Sauce', 'Homemade marinara sauce', NULL, 6, @loc_main_freezer, '2027-01-10', '2026-05-20'),
(UUID_TO_BIN(UUID()), 'Chicken Broth', 'Slow-cooked bone broth', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-11-30', '2026-04-15', NULL, FALSE), ('Chicken Broth', 'Slow-cooked bone broth', NULL, 3, @loc_main_freezer, '2026-11-30', '2026-04-15'),
(UUID_TO_BIN(UUID()), 'Blanched Green Beans', 'Garden green beans, blanched', NULL, 5, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-02-01', '2026-05-28', NULL, FALSE), ('Blanched Green Beans', 'Garden green beans, blanched', NULL, 5, @loc_main_freezer, '2027-02-01', '2026-05-28'),
(UUID_TO_BIN(UUID()), 'Pesto Cubes', 'Basil pesto in ice cube trays', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-12-01', '2026-05-10', NULL, FALSE), ('Pesto Cubes', 'Basil pesto in ice cube trays', NULL, 2, @loc_main_freezer, '2026-12-01', '2026-05-10'),
(UUID_TO_BIN(UUID()), 'Raspberry Jam', 'Homemade seedless raspberry jam', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-03-01', '2026-06-02', NULL, FALSE), ('Raspberry Jam', 'Homemade seedless raspberry jam', NULL, 3, @loc_main_freezer, '2027-03-01', '2026-06-02'),
(UUID_TO_BIN(UUID()), 'Beef Stew', 'Leftover beef stew, portioned', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-10-15', '2026-04-20', NULL, FALSE), ('Beef Stew', 'Leftover beef stew, portioned', NULL, 4, @loc_main_freezer, '2026-10-15', '2026-04-20'),
(UUID_TO_BIN(UUID()), 'Blanched Spinach', 'Fresh spinach, blanched and pressed', NULL, 6, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-01-20', '2026-05-15', NULL, FALSE), ('Blanched Spinach', 'Fresh spinach, blanched and pressed', NULL, 6, @loc_main_freezer, '2027-01-20', '2026-05-15'),
(UUID_TO_BIN(UUID()), 'Mango Chunks', 'Diced ripe mangoes', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-12-20', '2026-05-25', NULL, FALSE), ('Mango Chunks', 'Diced ripe mangoes', NULL, 3, @loc_main_freezer, '2026-12-20', '2026-05-25'),
(UUID_TO_BIN(UUID()), 'Carrot Soup', 'Pureed carrot ginger soup', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-11-01', '2026-04-10', NULL, FALSE), ('Carrot Soup', 'Pureed carrot ginger soup', NULL, 4, @loc_main_freezer, '2026-11-01', '2026-04-10'),
-- ── Self-frozen items (Kitchen Freezer) ── -- ── Self-frozen items (Kitchen Freezer) ──
(UUID_TO_BIN(UUID()), 'Blueberry Mix', 'Mixed blueberries from the garden', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-12-10', '2026-06-03', NULL, FALSE), ('Blueberry Mix', 'Mixed blueberries from the garden', NULL, 2, @loc_kitchen_freezer, '2026-12-10', '2026-06-03'),
(UUID_TO_BIN(UUID()), 'Zucchini Slices', 'Sliced and blanched zucchini', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-01-15', '2026-05-18', NULL, FALSE), ('Zucchini Slices', 'Sliced and blanched zucchini', NULL, 3, @loc_kitchen_freezer, '2027-01-15', '2026-05-18'),
(UUID_TO_BIN(UUID()), 'Apple Sauce', 'Homemade cinnamon apple sauce', NULL, 5, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-02-10', '2026-05-22', NULL, FALSE), ('Apple Sauce', 'Homemade cinnamon apple sauce', NULL, 5, @loc_kitchen_freezer, '2027-02-10', '2026-05-22'),
(UUID_TO_BIN(UUID()), 'Bolognese Sauce', 'Classic meat sauce, portioned', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-11-20', '2026-04-25', NULL, FALSE), ('Bolognese Sauce', 'Classic meat sauce, portioned', NULL, 3, @loc_kitchen_freezer, '2026-11-20', '2026-04-25'),
(UUID_TO_BIN(UUID()), 'Herb Butter', 'Parsley and garlic butter logs', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-12-30', '2026-05-30', NULL, FALSE), ('Herb Butter', 'Parsley and garlic butter logs', NULL, 4, @loc_kitchen_freezer, '2026-12-30', '2026-05-30'),
(UUID_TO_BIN(UUID()), 'Peach Slices', 'Frozen ripe peach slices', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-01-05', '2026-06-01', NULL, FALSE), ('Peach Slices', 'Frozen ripe peach slices', NULL, 2, @loc_kitchen_freezer, '2027-01-05', '2026-06-01'),
(UUID_TO_BIN(UUID()), 'Corn Kernels', 'Fresh sweet corn, cut off the cob', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-02-20', '2026-05-12', NULL, FALSE), ('Corn Kernels', 'Fresh sweet corn, cut off the cob', NULL, 3, @loc_kitchen_freezer, '2027-02-20', '2026-05-12'),
(UUID_TO_BIN(UUID()), 'Pumpkin Puree', 'Roasted and pureed butternut squash', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-03-10', '2026-05-08', NULL, FALSE), ('Pumpkin Puree', 'Roasted and pureed butternut squash', NULL, 4, @loc_kitchen_freezer, '2027-03-10', '2026-05-08'),
(UUID_TO_BIN(UUID()), 'Lemon Juice Cubes', 'Fresh lemon juice frozen in cubes', NULL, 6, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-04-01', '2026-06-02', NULL, FALSE), ('Lemon Juice Cubes', 'Fresh lemon juice frozen in cubes', NULL, 6, @loc_kitchen_freezer, '2027-04-01', '2026-06-02'),
(UUID_TO_BIN(UUID()), 'Minced Garlic', 'Peeled and minced garlic cloves', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-01-25', '2026-05-14', NULL, FALSE), ('Minced Garlic', 'Peeled and minced garlic cloves', NULL, 2, @loc_kitchen_freezer, '2027-01-25', '2026-05-14'),
-- ── Self-frozen items (split across storages) ── -- ── Self-frozen items (split across storages) ──
(UUID_TO_BIN(UUID()), 'Vegetable Stock', 'Homemade veggie stock', NULL, 5, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-12-05', '2026-04-30', NULL, FALSE), ('Vegetable Stock', 'Homemade veggie stock', NULL, 5, @loc_main_freezer, '2026-12-05', '2026-04-30'),
(UUID_TO_BIN(UUID()), 'Rhubarb Compote', 'Stewed rhubarb with sugar', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-01-30', '2026-05-05', NULL, FALSE), ('Rhubarb Compote', 'Stewed rhubarb with sugar', NULL, 3, @loc_main_freezer, '2027-01-30', '2026-05-05'),
(UUID_TO_BIN(UUID()), 'Cherry Halves', 'Pitted sour cherries', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-12-25', '2026-06-01', NULL, FALSE), ('Cherry Halves', 'Pitted sour cherries', NULL, 2, @loc_main_freezer, '2026-12-25', '2026-06-01'),
(UUID_TO_BIN(UUID()), 'Cauliflower Florets', 'Blanched cauliflower pieces', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-02-15', '2026-05-19', NULL, FALSE), ('Cauliflower Florets', 'Blanched cauliflower pieces', NULL, 4, @loc_kitchen_freezer, '2027-02-15', '2026-05-19'),
(UUID_TO_BIN(UUID()), 'Plum Jam', 'Homemade plum jam', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-03-20', '2026-05-27', NULL, FALSE), ('Plum Jam', 'Homemade plum jam', NULL, 3, @loc_main_freezer, '2027-03-20', '2026-05-27'),
(UUID_TO_BIN(UUID()), 'Mushroom Duxelles', 'Finely chopped sauteed mushrooms', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-11-10', '2026-04-18', NULL, FALSE), ('Mushroom Duxelles', 'Finely chopped sauteed mushrooms', NULL, 2, @loc_kitchen_freezer, '2026-11-10', '2026-04-18'),
(UUID_TO_BIN(UUID()), 'Chili Con Carne', 'Portioned homemade chili', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-10-30', '2026-04-12', NULL, FALSE), ('Chili Con Carne', 'Portioned homemade chili', NULL, 3, @loc_main_freezer, '2026-10-30', '2026-04-12'),
(UUID_TO_BIN(UUID()), 'Banana Slices', 'Ripe banana slices for smoothies', NULL, 5, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-12-08', '2026-06-03', NULL, FALSE), ('Banana Slices', 'Ripe banana slices for smoothies', NULL, 5, @loc_kitchen_freezer, '2026-12-08', '2026-06-03'),
(UUID_TO_BIN(UUID()), 'Parsley Cubes', 'Chopped parsley in olive oil cubes', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-04-15', '2026-05-31', NULL, FALSE), ('Parsley Cubes', 'Chopped parsley in olive oil cubes', NULL, 4, @loc_kitchen_freezer, '2027-04-15', '2026-05-31'),
(UUID_TO_BIN(UUID()), 'Lentil Soup', 'Thick homemade lentil soup', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-11-25', '2026-04-22', NULL, FALSE), ('Lentil Soup', 'Thick homemade lentil soup', NULL, 3, @loc_main_freezer, '2026-11-25', '2026-04-22'),
(UUID_TO_BIN(UUID()), 'Blackberry Puree', 'Strained wild blackberries', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-01-12', '2026-06-02', NULL, FALSE), ('Blackberry Puree', 'Strained wild blackberries', NULL, 2, @loc_main_freezer, '2027-01-12', '2026-06-02'),
(UUID_TO_BIN(UUID()), 'Roasted Bell Peppers', 'Peeled roasted red peppers', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-02-28', '2026-05-16', NULL, FALSE), ('Roasted Bell Peppers', 'Peeled roasted red peppers', NULL, 3, @loc_kitchen_freezer, '2027-02-28', '2026-05-16'),
(UUID_TO_BIN(UUID()), 'Goulash', 'Hungarian-style beef goulash', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-10-20', '2026-04-08', NULL, FALSE), ('Goulash', 'Hungarian-style beef goulash', NULL, 2, @loc_main_freezer, '2026-10-20', '2026-04-08'),
(UUID_TO_BIN(UUID()), 'Mixed Berry Smoothie Pack', 'Pre-portioned smoothie mix', NULL, 6, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-12-18', '2026-06-04', NULL, FALSE), ('Mixed Berry Smoothie Pack', 'Pre-portioned smoothie mix', NULL, 6, @loc_kitchen_freezer, '2026-12-18', '2026-06-04'),
(UUID_TO_BIN(UUID()), 'Leek Cream Soup', 'Pureed leek and potato soup', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-11-15', '2026-04-28', NULL, FALSE), ('Leek Cream Soup', 'Pureed leek and potato soup', NULL, 3, @loc_main_freezer, '2026-11-15', '2026-04-28'),
(UUID_TO_BIN(UUID()), 'Diced Onions', 'Pre-diced yellow onions', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-03-05', '2026-05-24', NULL, FALSE), ('Diced Onions', 'Pre-diced yellow onions', NULL, 4, @loc_kitchen_freezer, '2027-03-05', '2026-05-24'),
(UUID_TO_BIN(UUID()), 'Grape Juice Concentrate', 'Reduced grape juice for desserts', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-02-05', '2026-05-11', NULL, FALSE), ('Grape Juice Concentrate', 'Reduced grape juice for desserts', NULL, 2, @loc_main_freezer, '2027-02-05', '2026-05-11'),
(UUID_TO_BIN(UUID()), 'Currant Jelly', 'Red currant jelly', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-04-10', '2026-06-01', NULL, FALSE), ('Currant Jelly', 'Red currant jelly', NULL, 3, @loc_main_freezer, '2027-04-10', '2026-06-01'),
(UUID_TO_BIN(UUID()), 'Broccoli Florets', 'Blanched broccoli pieces', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-01-08', '2026-05-20', NULL, FALSE), ('Broccoli Florets', 'Blanched broccoli pieces', NULL, 4, @loc_kitchen_freezer, '2027-01-08', '2026-05-20'),
(UUID_TO_BIN(UUID()), 'Sweet Potato Mash', 'Roasted and mashed sweet potatoes', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-12-28', '2026-05-17', NULL, FALSE), ('Sweet Potato Mash', 'Roasted and mashed sweet potatoes', NULL, 3, @loc_main_freezer, '2026-12-28', '2026-05-17'),
(UUID_TO_BIN(UUID()), 'Apricot Halves', 'Frozen ripe apricot halves', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-02-12', '2026-06-03', NULL, FALSE), ('Apricot Halves', 'Frozen ripe apricot halves', NULL, 2, @loc_kitchen_freezer, '2027-02-12', '2026-06-03'),
(UUID_TO_BIN(UUID()), 'Minestrone', 'Chunky Italian vegetable soup', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-11-08', '2026-04-14', NULL, FALSE), ('Minestrone', 'Chunky Italian vegetable soup', NULL, 3, @loc_main_freezer, '2026-11-08', '2026-04-14'),
(UUID_TO_BIN(UUID()), 'Pea Puree', 'Fresh garden peas, pureed', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-03-15', '2026-05-26', NULL, FALSE), ('Pea Puree', 'Fresh garden peas, pureed', NULL, 4, @loc_kitchen_freezer, '2027-03-15', '2026-05-26'),
(UUID_TO_BIN(UUID()), 'Cranberry Sauce', 'Homemade whole berry cranberry sauce', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-01-18', '2026-05-09', NULL, FALSE), ('Cranberry Sauce', 'Homemade whole berry cranberry sauce', NULL, 2, @loc_main_freezer, '2027-01-18', '2026-05-09'),
(UUID_TO_BIN(UUID()), 'Egg Noodles', 'Homemade egg noodles, pre-cooked', NULL, 5, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2026-12-12', '2026-05-29', NULL, FALSE), ('Egg Noodles', 'Homemade egg noodles, pre-cooked', NULL, 5, @loc_kitchen_freezer, '2026-12-12', '2026-05-29'),
(UUID_TO_BIN(UUID()), 'Gooseberry Compote', 'Stewed gooseberries', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2027-02-22', '2026-06-02', NULL, FALSE), ('Gooseberry Compote', 'Stewed gooseberries', NULL, 2, @loc_main_freezer, '2027-02-22', '2026-06-02'),
(UUID_TO_BIN(UUID()), 'Creamed Corn', 'Homemade creamed corn', NULL, 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-01-28', '2026-05-21', NULL, FALSE), ('Creamed Corn', 'Homemade creamed corn', NULL, 3, @loc_kitchen_freezer, '2027-01-28', '2026-05-21'),
(UUID_TO_BIN(UUID()), 'Chicken Curry', 'Portioned Thai green curry', NULL, 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000001'), '2026-10-25', '2026-04-16', NULL, FALSE), ('Chicken Curry', 'Portioned Thai green curry', NULL, 2, @loc_main_freezer, '2026-10-25', '2026-04-16'),
(UUID_TO_BIN(UUID()), 'Dill Cubes', 'Chopped dill frozen in water cubes', NULL, 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000002'), '2027-04-20', '2026-06-01', NULL, FALSE), ('Dill Cubes', 'Chopped dill frozen in water cubes', NULL, 4, @loc_kitchen_freezer, '2027-04-20', '2026-06-01'),
-- ── Store-bought items (no bottling_date) ── -- ── Store-bought items (no bottling_date) ──
-- Kitchen Fridge -- Kitchen Fridge
(UUID_TO_BIN(UUID()), 'Whole Milk', 'Full-fat milk, 1 liter', '1.19', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-14', NULL, NULL, FALSE), ('Whole Milk', 'Full-fat milk, 1 liter', '1.19', 2, @loc_kitchen_fridge, '2026-06-14', NULL),
(UUID_TO_BIN(UUID()), 'Greek Yogurt', 'Plain Greek yogurt, 500g', '2.49', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-20', NULL, NULL, FALSE), ('Greek Yogurt', 'Plain Greek yogurt, 500g', '2.49', 3, @loc_kitchen_fridge, '2026-06-20', NULL),
(UUID_TO_BIN(UUID()), 'Butter', 'Unsalted butter, 250g', '2.29', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-07-15', NULL, NULL, FALSE), ('Butter', 'Unsalted butter, 250g', '2.29', 2, @loc_kitchen_fridge, '2026-07-15', NULL),
(UUID_TO_BIN(UUID()), 'Cheddar Cheese', 'Mature cheddar block, 400g', '3.49', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-08-10', NULL, NULL, FALSE), ('Cheddar Cheese', 'Mature cheddar block, 400g', '3.49', 1, @loc_kitchen_fridge, '2026-08-10', NULL),
(UUID_TO_BIN(UUID()), 'Cream Cheese', 'Philadelphia original, 200g', '1.89', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-25', NULL, NULL, FALSE), ('Cream Cheese', 'Philadelphia original, 200g', '1.89', 2, @loc_kitchen_fridge, '2026-06-25', NULL),
(UUID_TO_BIN(UUID()), 'Orange Juice', 'Fresh squeezed, 1 liter', '2.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-12', NULL, NULL, FALSE), ('Orange Juice', 'Fresh squeezed, 1 liter', '2.99', 1, @loc_kitchen_fridge, '2026-06-12', NULL),
(UUID_TO_BIN(UUID()), 'Eggs', 'Free-range eggs, pack of 10', '3.29', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-18', NULL, NULL, FALSE), ('Eggs', 'Free-range eggs, pack of 10', '3.29', 1, @loc_kitchen_fridge, '2026-06-18', NULL),
(UUID_TO_BIN(UUID()), 'Ham Slices', 'Cooked ham, 200g pack', '2.19', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-10', NULL, NULL, FALSE), ('Ham Slices', 'Cooked ham, 200g pack', '2.19', 2, @loc_kitchen_fridge, '2026-06-10', NULL),
(UUID_TO_BIN(UUID()), 'Mozzarella', 'Fresh mozzarella ball, 125g', '1.49', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-09', NULL, NULL, FALSE), ('Mozzarella', 'Fresh mozzarella ball, 125g', '1.49', 3, @loc_kitchen_fridge, '2026-06-09', NULL),
(UUID_TO_BIN(UUID()), 'Hummus', 'Classic chickpea hummus, 300g', '2.29', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-15', NULL, NULL, FALSE), ('Hummus', 'Classic chickpea hummus, 300g', '2.29', 1, @loc_kitchen_fridge, '2026-06-15', NULL),
-- Pantry -- Pantry
(UUID_TO_BIN(UUID()), 'Pasta Spaghetti', 'Durum wheat spaghetti, 500g', '1.29', 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-03-01', NULL, NULL, FALSE), ('Pasta Spaghetti', 'Durum wheat spaghetti, 500g', '1.29', 4, @loc_pantry, '2028-03-01', NULL),
(UUID_TO_BIN(UUID()), 'Basmati Rice', 'Long grain basmati, 1kg', '2.99', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-06-01', NULL, NULL, FALSE), ('Basmati Rice', 'Long grain basmati, 1kg', '2.99', 2, @loc_pantry, '2028-06-01', NULL),
(UUID_TO_BIN(UUID()), 'Canned Tomatoes', 'Chopped tomatoes, 400g tin', '0.89', 6, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-01-15', NULL, NULL, FALSE), ('Canned Tomatoes', 'Chopped tomatoes, 400g tin', '0.89', 6, @loc_pantry, '2028-01-15', NULL),
(UUID_TO_BIN(UUID()), 'Canned Chickpeas', 'Chickpeas in water, 400g', '0.99', 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-04-10', NULL, NULL, FALSE), ('Canned Chickpeas', 'Chickpeas in water, 400g', '0.99', 4, @loc_pantry, '2028-04-10', NULL),
(UUID_TO_BIN(UUID()), 'Olive Oil', 'Extra virgin olive oil, 500ml', '5.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2027-09-01', NULL, NULL, FALSE), ('Olive Oil', 'Extra virgin olive oil, 500ml', '5.99', 1, @loc_pantry, '2027-09-01', NULL),
(UUID_TO_BIN(UUID()), 'Peanut Butter', 'Crunchy peanut butter, 350g', '3.49', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2027-02-01', NULL, NULL, FALSE), ('Peanut Butter', 'Crunchy peanut butter, 350g', '3.49', 2, @loc_pantry, '2027-02-01', NULL),
(UUID_TO_BIN(UUID()), 'Honey', 'Raw wildflower honey, 500g', '6.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-12-01', NULL, NULL, FALSE), ('Honey', 'Raw wildflower honey, 500g', '6.99', 1, @loc_pantry, '2028-12-01', NULL),
(UUID_TO_BIN(UUID()), 'Oats', 'Rolled oats, 1kg', '1.99', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2027-08-01', NULL, NULL, FALSE), ('Oats', 'Rolled oats, 1kg', '1.99', 2, @loc_pantry, '2027-08-01', NULL),
(UUID_TO_BIN(UUID()), 'Flour', 'All-purpose wheat flour, 1kg', '0.99', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2027-06-01', NULL, NULL, FALSE), ('Flour', 'All-purpose wheat flour, 1kg', '0.99', 3, @loc_pantry, '2027-06-01', NULL),
(UUID_TO_BIN(UUID()), 'Sugar', 'White granulated sugar, 1kg', '1.49', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2029-01-01', NULL, NULL, FALSE), ('Sugar', 'White granulated sugar, 1kg', '1.49', 2, @loc_pantry, '2029-01-01', NULL),
(UUID_TO_BIN(UUID()), 'Canned Tuna', 'Tuna chunks in sunflower oil, 185g', '1.79', 5, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-07-01', NULL, NULL, FALSE), ('Canned Tuna', 'Tuna chunks in sunflower oil, 185g', '1.79', 5, @loc_pantry, '2028-07-01', NULL),
(UUID_TO_BIN(UUID()), 'Coconut Milk', 'Full-fat coconut milk, 400ml', '1.59', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-02-01', NULL, NULL, FALSE), ('Coconut Milk', 'Full-fat coconut milk, 400ml', '1.59', 3, @loc_pantry, '2028-02-01', NULL),
(UUID_TO_BIN(UUID()), 'Soy Sauce', 'Naturally brewed soy sauce, 250ml', '2.49', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-05-01', NULL, NULL, FALSE), ('Soy Sauce', 'Naturally brewed soy sauce, 250ml', '2.49', 1, @loc_pantry, '2028-05-01', NULL),
(UUID_TO_BIN(UUID()), 'Balsamic Vinegar', 'Aged balsamic vinegar, 250ml', '3.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2029-06-01', NULL, NULL, FALSE), ('Balsamic Vinegar', 'Aged balsamic vinegar, 250ml', '3.99', 1, @loc_pantry, '2029-06-01', NULL),
(UUID_TO_BIN(UUID()), 'Dried Lentils', 'Green lentils, 500g', '1.69', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-09-01', NULL, NULL, FALSE), ('Dried Lentils', 'Green lentils, 500g', '1.69', 2, @loc_pantry, '2028-09-01', NULL),
(UUID_TO_BIN(UUID()), 'Cornflakes', 'Classic cornflakes, 500g', '2.19', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2027-04-01', NULL, NULL, FALSE), ('Cornflakes', 'Classic cornflakes, 500g', '2.19', 1, @loc_pantry, '2027-04-01', NULL),
(UUID_TO_BIN(UUID()), 'Black Beans', 'Canned black beans, 400g', '1.09', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-08-01', NULL, NULL, FALSE), ('Black Beans', 'Canned black beans, 400g', '1.09', 3, @loc_pantry, '2028-08-01', NULL),
(UUID_TO_BIN(UUID()), 'Mustard', 'Dijon mustard, 200g jar', '1.89', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2027-11-01', NULL, NULL, FALSE), ('Mustard', 'Dijon mustard, 200g jar', '1.89', 1, @loc_pantry, '2027-11-01', NULL),
(UUID_TO_BIN(UUID()), 'Dried Pasta Penne', 'Penne rigate, 500g', '1.19', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-05-15', NULL, NULL, FALSE), ('Dried Pasta Penne', 'Penne rigate, 500g', '1.19', 3, @loc_pantry, '2028-05-15', NULL),
(UUID_TO_BIN(UUID()), 'Maple Syrup', 'Pure Canadian maple syrup, 250ml', '7.49', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000003'), '2028-10-01', NULL, NULL, FALSE), ('Maple Syrup', 'Pure Canadian maple syrup, 250ml', '7.49', 1, @loc_pantry, '2028-10-01', NULL),
-- Cellar Shelf -- Cellar Shelf
(UUID_TO_BIN(UUID()), 'Canned Corn', 'Sweet corn kernels, 340g', '1.09', 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2028-03-15', NULL, NULL, FALSE), ('Canned Corn', 'Sweet corn kernels, 340g', '1.09', 4, @loc_cellar_shelf, '2028-03-15', NULL),
(UUID_TO_BIN(UUID()), 'Canned Peas', 'Garden peas, 400g tin', '0.89', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2028-06-10', NULL, NULL, FALSE), ('Canned Peas', 'Garden peas, 400g tin', '0.89', 3, @loc_cellar_shelf, '2028-06-10', NULL),
(UUID_TO_BIN(UUID()), 'Apple Cider Vinegar', 'Organic apple cider vinegar, 500ml', '3.29', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2028-11-01', NULL, NULL, FALSE), ('Apple Cider Vinegar', 'Organic apple cider vinegar, 500ml', '3.29', 2, @loc_cellar_shelf, '2028-11-01', NULL),
(UUID_TO_BIN(UUID()), 'Sunflower Oil', 'Refined sunflower oil, 1 liter', '2.49', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2027-10-01', NULL, NULL, FALSE), ('Sunflower Oil', 'Refined sunflower oil, 1 liter', '2.49', 1, @loc_cellar_shelf, '2027-10-01', NULL),
(UUID_TO_BIN(UUID()), 'Tomato Paste', 'Double concentrated, 200g tube', '1.29', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2028-01-01', NULL, NULL, FALSE), ('Tomato Paste', 'Double concentrated, 200g tube', '1.29', 3, @loc_cellar_shelf, '2028-01-01', NULL),
(UUID_TO_BIN(UUID()), 'Canned Sardines', 'Sardines in olive oil, 120g', '2.19', 4, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2028-09-15', NULL, NULL, FALSE), ('Canned Sardines', 'Sardines in olive oil, 120g', '2.19', 4, @loc_cellar_shelf, '2028-09-15', NULL),
(UUID_TO_BIN(UUID()), 'Pickled Cucumbers', 'Whole pickled gherkins, 670g jar', '1.99', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2027-12-01', NULL, NULL, FALSE), ('Pickled Cucumbers', 'Whole pickled gherkins, 670g jar', '1.99', 2, @loc_cellar_shelf, '2027-12-01', NULL),
(UUID_TO_BIN(UUID()), 'Jam Strawberry', 'Store-bought strawberry jam, 450g', '2.79', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2027-07-01', NULL, NULL, FALSE), ('Jam Strawberry', 'Store-bought strawberry jam, 450g', '2.79', 1, @loc_cellar_shelf, '2027-07-01', NULL),
(UUID_TO_BIN(UUID()), 'Canned Peaches', 'Peach halves in syrup, 420g', '1.69', 3, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2028-04-01', NULL, NULL, FALSE), ('Canned Peaches', 'Peach halves in syrup, 420g', '1.69', 3, @loc_cellar_shelf, '2028-04-01', NULL),
(UUID_TO_BIN(UUID()), 'Crackers', 'Whole wheat crackers, 200g', '1.89', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000005'), '2027-05-01', NULL, NULL, FALSE), ('Crackers', 'Whole wheat crackers, 200g', '1.89', 2, @loc_cellar_shelf, '2027-05-01', NULL),
-- Additional Kitchen Fridge items -- Additional Kitchen Fridge items
(UUID_TO_BIN(UUID()), 'Salami', 'Italian salami slices, 100g', '2.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-20', NULL, NULL, FALSE), ('Salami', 'Italian salami slices, 100g', '2.99', 1, @loc_kitchen_fridge, '2026-06-20', NULL),
(UUID_TO_BIN(UUID()), 'Parmesan', 'Parmigiano Reggiano wedge, 200g', '4.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-09-01', NULL, NULL, FALSE), ('Parmesan', 'Parmigiano Reggiano wedge, 200g', '4.99', 1, @loc_kitchen_fridge, '2026-09-01', NULL),
(UUID_TO_BIN(UUID()), 'Smoked Salmon', 'Sliced smoked salmon, 100g', '3.99', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-08', NULL, NULL, FALSE), ('Smoked Salmon', 'Sliced smoked salmon, 100g', '3.99', 1, @loc_kitchen_fridge, '2026-06-08', NULL),
(UUID_TO_BIN(UUID()), 'Heavy Cream', 'Whipping cream 35%, 200ml', '1.29', 2, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-16', NULL, NULL, FALSE), ('Heavy Cream', 'Whipping cream 35%, 200ml', '1.29', 2, @loc_kitchen_fridge, '2026-06-16', NULL),
(UUID_TO_BIN(UUID()), 'Tofu', 'Firm organic tofu, 400g', '2.49', 1, UUID_TO_BIN('a1000000-0000-0000-0000-000000000004'), '2026-06-22', NULL, NULL, FALSE); ('Tofu', 'Firm organic tofu, 400g', '2.49', 1, @loc_kitchen_fridge, '2026-06-22', NULL);
+11
View File
@@ -0,0 +1,11 @@
import type {AuthTokenPayload} from "../services/tokenService.js";
declare global {
namespace Express {
interface Request {
user?: AuthTokenPayload;
}
}
}
export {};
+8
View File
@@ -0,0 +1,8 @@
export type ErrorCode = readonly [code: string, message: string];
export interface Response {
success: boolean;
code: string;
data: any;
message: string;
}
-1096
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -2,22 +2,29 @@
"name": "backend", "name": "backend",
"version": "0.0.0-dev", "version": "0.0.0-dev",
"description": "", "description": "",
"main": "server.js", "main": "server.ts",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "dev": "tsx --watch server.ts",
"start": "node server.js", "build": "tsc -b && node scripts/copy-assets.cjs",
"dev": "node --watch server.js" "start": "node dist/server.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "module", "type": "commonjs",
"dependencies": { "dependencies": {
"@stockhome/shared": "1.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.16.0" "mysql2": "^3.16.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^26.1.0",
"typescript": "~6.0.2",
"tsx": "^4.23.0"
} }
} }
@@ -1,153 +0,0 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const newProduct = async (
name,
description,
price,
amount,
storage_location,
expiry_date,
bottling_date,
) => {
let newPrice;
if (price == "") {
newPrice = null;
} else {
newPrice = price;
}
const [result] = await pool.query(
"INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES (?, ?, ?, ?, UUID_TO_BIN(?), ?, ?)",
[
name,
description,
newPrice,
amount,
storage_location,
expiry_date,
bottling_date,
],
);
if (result.affectedRows > 0) {
return { code: "sp001" }; // success
} else {
return { code: "ep001" }; // error
}
};
export const productDetails = async (uuid) => {
const [result] = await pool.query(
`SELECT
BIN_TO_UUID(p.uuid) AS uuid,
p.name,
p.description,
p.price,
p.amount,
BIN_TO_UUID(s.uuid) AS storage_location_uuid,
s.name AS storage_location_name,
p.expiry_date,
p.bottling_date,
p.picture
FROM products p
JOIN storage_locations s ON p.storage_location = s.uuid
WHERE p.uuid = UUID_TO_BIN(?)`,
[uuid],
);
if (result.length > 0) {
return { code: "sp003", data: result[0] };
} else {
return { code: "ep003" };
}
};
export const allProducts = async () => {
const [result] = await pool.query(`
SELECT
BIN_TO_UUID(p.uuid) AS uuid,
p.name,
p.description,
p.price,
p.amount,
BIN_TO_UUID(s.uuid) AS storage_location_uuid,
s.name AS storage_location_name,
p.expiry_date,
p.bottling_date,
p.picture,
p.created_at,
p.updated_at
FROM products p
JOIN storage_locations s ON p.storage_location = s.uuid
WHERE p.deleted = 0
`);
if (result.length > 0) {
return { code: "sp002", data: result };
} else {
return { code: "ep002" };
}
};
export const setAmount = async (itemUUID, amount) => {
const [result] = await pool.query(
`
UPDATE products SET amount = ? WHERE uuid = UUID_TO_BIN(?)
`,
[amount, itemUUID],
);
if (result.affectedRows > 0) {
return { code: "sp004" }; // success
} else {
return { code: "ep004" }; // error
}
};
export const updateItem = async (itemUUID, newValues) => {
const [result] = await pool.query(
`
UPDATE products SET name = ?, description = ?, price = ?, amount = ?, storage_location = UUID_TO_BIN(?), expiry_date = ?, bottling_date = ? WHERE uuid = UUID_TO_BIN(?);
`,
[
newValues.name,
newValues.description,
newValues.price,
newValues.amount,
newValues.storage_location_uuid,
newValues.expiry_date,
newValues.bottling_date,
itemUUID,
],
);
if (result.affectedRows > 0) {
return { code: "sp005" }; // success
} else {
return { code: "ep005" }; // error
}
};
export const deleteProduct = async (uuid) => {
const [result] = await pool.query(
`UPDATE products SET deleted = 1 WHERE uuid = UUID_TO_BIN(?);`,
[uuid],
);
if (result.affectedRows > 0) {
return { code: "sp006" }; // success
} else {
return { code: "ep006" }; // error
}
};
@@ -0,0 +1,201 @@
import mysql, {
type ResultSetHeader,
type RowDataPacket,
} from "mysql2/promise";
import dotenv from "dotenv";
import { returnErrorCode } from "../../../services/helperFuncs.js";
import { PRODUCT_ERROR_CODE } from "@stockhome/shared";
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
});
export const newProduct = async (
name: string,
description: string,
price: string,
amount: string,
storage_location: string,
expiry_date: string,
bottling_date: string,
): Promise<
boolean | { success: boolean; code: string; data: null; message: string }
> => {
const newPrice = price !== "" ? price : null;
const [result] = await pool.query<ResultSetHeader>(
"INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES (?, ?, ?, ?, UUID_TO_BIN(?), ?, ?)",
[
name,
description,
newPrice,
amount,
storage_location,
expiry_date,
bottling_date,
],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SP001",
data: null,
message: "New Product successfully created.",
};
}
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_NOT_CREATED);
};
export const productDetails = async (uuid: string) => {
const [result] = await pool.query<RowDataPacket[]>(
`SELECT BIN_TO_UUID(p.uuid) AS uuid,
p.name,
p.description,
p.price,
p.amount,
BIN_TO_UUID(s.uuid) AS storage_location_uuid,
s.name AS storage_location_name,
p.expiry_date,
p.bottling_date,
p.picture
FROM products p
JOIN storage_locations s ON p.storage_location = s.uuid
WHERE p.uuid = UUID_TO_BIN(?)`,
[uuid],
);
if (result.length > 0) {
return {
success: true,
code: "SP003",
data: result[0],
message: "Product details successfully loaded.",
};
}
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_NOT_FOUND);
};
export const allProducts = async () => {
const [result] = await pool.query<RowDataPacket[]>(`
SELECT BIN_TO_UUID(p.uuid) AS uuid,
p.name,
p.description,
p.price,
p.amount,
BIN_TO_UUID(s.uuid) AS storage_location_uuid,
s.name AS storage_location_name,
p.expiry_date,
p.bottling_date,
p.picture,
p.created_at,
p.updated_at
FROM products p
JOIN storage_locations s ON p.storage_location = s.uuid
WHERE p.deleted = 0
`);
if (result.length > 0) {
return {
success: true,
code: "SP002",
data: result,
message: "All products successfully loaded.",
};
}
return returnErrorCode(PRODUCT_ERROR_CODE.NO_PRODUCTS_FOUND);
};
export const setAmount = async (itemUUID: string, amount: number) => {
const [result] = await pool.query<ResultSetHeader>(
`UPDATE products
SET amount = ?
WHERE uuid = UUID_TO_BIN(?)`,
[amount, itemUUID],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SP004",
data: null,
message: "Amount successfully updated.",
};
}
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_AMOUNT_NOT_UPDATED);
};
export const updateItem = async (
itemUUID: string,
newValues: {
name: string;
description: string;
price: string;
amount: string;
storage_location_uuid: string;
expiry_date: string;
bottling_date: string;
},
) => {
const [result] = await pool.query<ResultSetHeader>(
`UPDATE products
SET name = ?,
description = ?,
price = ?,
amount = ?,
storage_location = UUID_TO_BIN(?),
expiry_date = ?,
bottling_date = ?
WHERE uuid = UUID_TO_BIN(?);`,
[
newValues.name,
newValues.description,
newValues.price,
newValues.amount,
newValues.storage_location_uuid,
newValues.expiry_date,
newValues.bottling_date,
itemUUID,
],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SP005",
data: null,
message: "Item updated successfully.",
};
}
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_NOT_UPDATED);
};
export const deleteProduct = async (uuid: string) => {
const [result] = await pool.query<ResultSetHeader>(
`UPDATE products
SET deleted = 1
WHERE uuid = UUID_TO_BIN(?);`,
[uuid],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SP006",
data: null,
message: "Product deleted successfully.",
};
}
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_NOT_DELETED);
};
@@ -1,63 +0,0 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const allStorages = async () => {
const [result] = await pool.query(
"SELECT BIN_TO_UUID(uuid) AS uuid, name, description, created_at, updated_at FROM storage_locations;",
);
if (result.length > 0) {
return { code: "ss001", data: result };
} else {
return { code: "es001" };
}
};
export const newStorage = async (name, description) => {
const [result] = await pool.query(
"INSERT INTO storage_locations (name, description) VALUES (?, ?)",
[name, description],
);
if (result.affectedRows > 0) {
return { code: "ss002" };
} else {
return { code: "es002" };
}
};
export const updateStorage = async (uuid, values) => {
const [result] = await pool.query(
"UPDATE storage_locations SET name = ?, description = ? WHERE uuid = UUID_TO_BIN(?);",
[values.name, values.description, uuid],
);
if (result.affectedRows > 0) {
return { code: "ss003" };
} else {
return { code: "es003" };
}
};
export const deleteStorage = async (uuid) => {
const [result] = await pool.query(
"DELETE FROM storage_locations WHERE uuid = UUID_TO_BIN(?);",
[uuid],
);
if (result.affectedRows > 0) {
return { code: "ss004" };
} else {
return { code: "es004" };
}
};
@@ -0,0 +1,90 @@
import mysql, {
type ResultSetHeader,
type RowDataPacket,
} from "mysql2/promise";
import dotenv from "dotenv";
import { STORAGE_ERROR_CODE } from "@stockhome/shared";
import { returnErrorCode } from "../../../services/helperFuncs.js";
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
});
export const allStorages = async () => {
const [result] = await pool.query<RowDataPacket[]>(
"SELECT BIN_TO_UUID(uuid) AS uuid, name, description, created_at, updated_at FROM storage_locations;",
);
if (result.length > 0) {
return {
success: true,
code: "SS001",
data: result,
message: "Successfully fetched all storage locations.",
};
}
return returnErrorCode(STORAGE_ERROR_CODE.NO_STORAGE_LOCATIONS_FOUND);
};
export const newStorage = async (name: string, description: string) => {
const [result] = await pool.query<ResultSetHeader>(
"INSERT INTO storage_locations (name, description) VALUES (?, ?)",
[name, description],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SS002",
data: null,
message: "Successfully created new storage location.",
};
}
return returnErrorCode(STORAGE_ERROR_CODE.STORAGE_NOT_CREATED);
};
export const updateStorage = async (
uuid: string,
values: { name: string; description: string },
) => {
const [result] = await pool.query<ResultSetHeader>(
"UPDATE storage_locations SET name = ?, description = ? WHERE uuid = UUID_TO_BIN(?);",
[values.name, values.description, uuid],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SS003",
data: null,
message: "Successfully updated storage location.",
};
}
return returnErrorCode(STORAGE_ERROR_CODE.STORAGE_NOT_UPDATED);
};
export const deleteStorage = async (uuid: string) => {
const [result] = await pool.query<ResultSetHeader>(
"DELETE FROM storage_locations WHERE uuid = UUID_TO_BIN(?);",
[uuid],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SS004",
data: null,
message: "Successfully deleted storage location.",
};
}
return returnErrorCode(STORAGE_ERROR_CODE.STORAGE_NOT_DELETED);
};
@@ -1,91 +0,0 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const findUser = async (username, password) => {
const [result] = await pool.query(
"SELECT BIN_TO_UUID(uuid) AS uuid, username, first_name, last_name, email, is_admin, is_active, last_login FROM users WHERE username = ? AND password = ?;",
[username, password],
);
if (result.length <= 0) {
return { code: "eu001" }; // username or password is wrong
}
if (!result[0].is_active) {
return { code: "eu002" }; // user is deactivated
}
return { code: "su001", data: result[0] }; // user found
};
export const loginUser = async (username) => {
const [result] = await pool.query(
"UPDATE users SET last_login = NOW() WHERE username = ?;",
[username],
);
if (result.affectedRows > 0) {
return { code: "su002" };
} else {
return { code: "eu003" };
}
};
export const updateSettings = async (payload) => {
const appName = payload["app-name"];
const currency = payload.currency;
const [result] = await pool.query(
`UPDATE app_settings
SET value = CASE name
WHEN "app-name" THEN ?
WHEN "currency" THEN ?
ELSE value
END
WHERE name IN ("app-name", "currency");`,
[appName, currency],
);
if (result.affectedRows > 0) {
return { code: "su003" };
} else {
return { code: "eu004" };
}
};
export const getSettings = async () => {
const [result] = await pool.query(`SELECT * FROM app_settings;`);
if (result.length > 0) {
return { code: "su004", result };
} else {
return { code: "eu005" };
}
};
export const changePassword = async (
username,
currentPasswordUser,
newPassword,
) => {
const [result] = await pool.query(
`UPDATE users SET password = ? WHERE username = ? AND password = ?;`,
[newPassword, username, currentPasswordUser],
);
if (result.affectedRows > 0) {
return { code: "su005" };
} else {
return { code: "eu006" };
}
};
@@ -0,0 +1,135 @@
import mysql, {
type ResultSetHeader,
type RowDataPacket,
} from "mysql2/promise";
import dotenv from "dotenv";
import { GENERAL_ERROR_CODE, USER_ERROR_CODE } from "@stockhome/shared";
import { returnErrorCode } from "../../../services/helperFuncs.js";
import type { AuthTokenPayload } from "../../../services/tokenService";
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
});
export const findUser = async (username: string, password: string) => {
const [result] = await pool.query<RowDataPacket[]>(
"SELECT BIN_TO_UUID(uuid) AS uuid, username, first_name, last_name, email, is_admin, is_active, last_login FROM users WHERE username = ? AND password = ?;",
[username, password],
);
const userRow = result[0];
if (!userRow) {
return returnErrorCode(USER_ERROR_CODE.WRONG_USERNAME_PASSWORD);
}
// Cast DB row to AuthTokenPayload for token generation / typing
const user = userRow as unknown as AuthTokenPayload;
if (!user.is_active) {
return returnErrorCode(USER_ERROR_CODE.USER_IS_DEACTIVATED);
}
return {
success: true,
code: "SU001",
data: user,
message: "Successfully found user.",
};
};
export const loginUser = async (username: string) => {
const [result] = await pool.query<ResultSetHeader>(
"UPDATE users SET last_login = NOW() WHERE username = ?;",
[username],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SU002",
data: null,
message: "Successfully logged in user.",
};
} else {
return returnErrorCode(GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR);
}
};
export const updateSettings = async (payload: {
"app-name": string;
currency: string;
}) => {
const appName = payload["app-name"];
const currency = payload.currency;
const [result] = await pool.query<ResultSetHeader>(
`UPDATE app_settings
SET value = CASE name
WHEN "app-name" THEN ?
WHEN "currency" THEN ?
ELSE value
END
WHERE name IN ("app-name", "currency");`,
[appName, currency],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SU003",
data: null,
message: "Successfully updated settings.",
};
} else {
return returnErrorCode(GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR);
}
};
export const getSettings = async () => {
const [result] = await pool.query<RowDataPacket[]>(
`SELECT *
FROM app_settings;`,
);
if (result.length > 0) {
return {
success: true,
code: "SU004",
data: result,
message: "Successfully fetched settings.",
};
} else {
return returnErrorCode(GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR);
}
};
export const changePassword = async (
username: string,
currentPasswordUser: string,
newPassword: string,
) => {
const [result] = await pool.query<ResultSetHeader>(
`UPDATE users
SET password = ?
WHERE username = ?
AND password = ?;`,
[newPassword, username, currentPasswordUser],
);
if (result.affectedRows > 0) {
return {
success: true,
code: "SU005",
data: null,
message: "Successfully fetched settings.",
};
} else {
return returnErrorCode(USER_ERROR_CODE.PASSWORD_CHANGE_FAILED);
}
};
-180
View File
@@ -1,180 +0,0 @@
import express from "express";
import dotenv from "dotenv";
import { authenticate } from "../../services/tokenService.js";
import {
allProducts,
deleteProduct,
newProduct,
productDetails,
setAmount,
updateItem,
} from "./database/products.database.js";
dotenv.config();
const router = express.Router();
router.post("/new-product", authenticate, async (req, res) => {
const {
name,
description,
price,
amount,
storage_location,
expiry_date,
bottling_date,
} = req.body;
const result = await newProduct(
name,
description,
price,
amount,
storage_location,
expiry_date,
bottling_date,
);
if (result.code === "ep001") {
res.status(406).json({
success: false,
code: "ep001",
data: null,
message: "Error while creating product",
});
}
if (result.code === "sp001") {
res.status(201).json({
success: true,
code: "sp001",
data: null,
message: "",
});
}
});
router.get("/all-products", authenticate, async (req, res) => {
const result = await allProducts();
if (result.code === "ep002") {
res.status(406).json({
success: false,
code: "ep002",
data: null,
message: "Error while fetching products",
});
}
if (result.code === "sp002") {
res.status(200).json({
success: true,
code: "sp002",
data: result.data,
message: "",
});
}
});
router.get("/view", async (req, res) => {
const uuid = req.query.uuid;
const result = await productDetails(uuid);
if (result.code === "ep003") {
res.status(406).json({
success: false,
code: "ep003",
data: null,
message: "Error while fetching product",
});
}
if (result.code === "sp003") {
res.status(200).json({
success: true,
code: "sp003",
data: result.data,
message: "",
});
}
});
router.put("/mutate/set-amount", authenticate, async (req, res) => {
const amount = req.query.amount;
const itemUUID = req.query.item;
const result = await setAmount(itemUUID, amount);
if (result.code === "ep004") {
res.status(406).json({
success: false,
code: "ep004",
data: null,
message: "Error while updating product amount",
});
}
if (result.code === "sp004") {
res.status(200).json({
success: true,
code: "sp004",
data: null,
message: "",
});
}
});
router.post("/mutate/update-item", async (req, res) => {
const itemUUID = req.query.item;
const newValues = req.body;
const result = await updateItem(itemUUID, newValues);
if (result.code === "ep005") {
res.status(406).json({
success: false,
code: "ep005",
data: null,
message: "Error while updating product",
});
}
if (result.code === "sp005") {
res.status(200).json({
success: true,
code: "sp005",
data: null,
message: "",
});
}
});
router.post("/delete-selection", authenticate, async (req, res) => {
let isError = false;
const uuidArray = req.body;
for (const uuid of uuidArray) {
const response = await deleteProduct(uuid);
if (response.code === "ep006" || !response) {
isError = true;
break;
}
}
if (isError === false) {
res.status(202).json({
success: true,
code: "sp006",
data: null,
message: "",
});
} else {
res.status(500).json({
success: false,
code: "ep006",
data: null,
message: "",
});
}
});
export default router;
+284
View File
@@ -0,0 +1,284 @@
import express from "express";
import dotenv from "dotenv";
import { authenticate } from "../../services/tokenService";
import {
allProducts,
deleteProduct,
newProduct,
productDetails,
setAmount,
updateItem,
} from "./database/products.database";
import { GENERAL_ERROR_CODE, PRODUCT_ERROR_CODE } from "@stockhome/shared";
dotenv.config();
const router = express.Router();
router.post("/new-product", authenticate, async (req, res) => {
const {
name,
description,
price,
amount,
storage_location,
expiry_date,
bottling_date,
} = req.body;
if (!name) {
return res.status(400).json({
success: false,
code: PRODUCT_ERROR_CODE.INVALID_REQUEST_BODY[0],
data: null,
message: PRODUCT_ERROR_CODE.INVALID_REQUEST_BODY[1],
});
}
const result = await newProduct(
name,
description,
price,
amount,
storage_location,
expiry_date,
bottling_date,
);
if (!result || typeof result !== "object") {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SP001") {
return res.status(201).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.get("/all-products", authenticate, async (req, res) => {
const result = await allProducts();
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SP002") {
return res.status(200).json({
success: true,
code: result.code,
data: result.data,
message: result.message,
});
}
if (result.code === PRODUCT_ERROR_CODE.NO_PRODUCTS_FOUND[0]) {
return res.status(404).json({
success: false,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.get("/view", async (req, res) => {
const uuid = req.query.uuid;
if (typeof uuid !== "string") {
return res.status(400).json({
success: false,
code: PRODUCT_ERROR_CODE.INVALID_PARAMETERS[0],
data: null,
message: PRODUCT_ERROR_CODE.INVALID_PARAMETERS[1],
});
}
const result = await productDetails(uuid);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SP003") {
return res.status(200).json({
success: true,
code: result.code,
data: result.data,
message: result.message,
});
}
if (result.code === PRODUCT_ERROR_CODE.PRODUCT_NOT_FOUND[0]) {
return res.status(404).json({
success: false,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.put("/mutate/set-amount", authenticate, async (req, res) => {
const amount = req.query.amount;
const itemUUID = req.query.item;
if (typeof itemUUID !== "string" || typeof amount !== "number") {
return res.status(400).json({
success: false,
code: PRODUCT_ERROR_CODE.INVALID_PARAMETERS[0],
data: null,
message: PRODUCT_ERROR_CODE.INVALID_PARAMETERS[1],
});
}
const result = await setAmount(itemUUID, amount);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SP004") {
return res.status(200).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.post("/mutate/update-item", authenticate, async (req, res) => {
const itemUUID = req.query.item;
const newValues = req.body;
if (typeof itemUUID !== "string" || !newValues) {
return res.status(400).json({
success: false,
code: PRODUCT_ERROR_CODE.INVALID_PARAMETERS[0],
data: null,
message: PRODUCT_ERROR_CODE.INVALID_PARAMETERS[1],
});
}
const result = await updateItem(itemUUID, newValues);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SP005") {
return res.status(200).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.post("/delete-selection", authenticate, async (req, res) => {
const uuidArray = req.body;
if (!Array.isArray(uuidArray) || uuidArray.length === 0) {
return res.status(400).json({
success: false,
code: PRODUCT_ERROR_CODE.INVALID_REQUEST_BODY[0],
data: null,
message: PRODUCT_ERROR_CODE.INVALID_REQUEST_BODY[1],
});
}
for (const uuid of uuidArray) {
const response = await deleteProduct(uuid);
if (!response) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (response.code !== "SP006") {
return res.status(500).json({
success: false,
code: response.code || PRODUCT_ERROR_CODE.PRODUCT_NOT_DELETED[0],
data: null,
message: response.message || PRODUCT_ERROR_CODE.PRODUCT_NOT_DELETED[1],
});
}
}
return res.status(202).json({
success: true,
code: "SP006",
data: null,
message: "All selected products deleted successfully.",
});
});
export default router;
-124
View File
@@ -1,124 +0,0 @@
import express from "express";
import dotenv from "dotenv";
import { authenticate } from "../../services/tokenService.js";
import {
allStorages,
newStorage,
updateStorage,
deleteStorage,
} from "./database/storage.database.js";
dotenv.config();
const router = express.Router();
router.get("/all-storages", authenticate, async (req, res) => {
const result = await allStorages();
if (result.code === "es001") {
res.status(500).json({
success: false,
code: "es001",
data: null,
message: "unexpected server error",
});
}
if (result.code === "ss001") {
res.status(200).json({
success: true,
code: "ss001",
data: result.data,
message: "",
});
}
});
router.post("/new-storage", authenticate, async (req, res) => {
const { name, description } = req.body;
let desc = description;
if (!name) {
res.status(400).json({
success: false,
code: "es000",
data: null,
message: "invalid request body",
});
return;
}
if (description == "") {
desc = null;
}
const result = await newStorage(name, desc);
if (result.code === "es002") {
res.status(500).json({
success: false,
code: "es002",
data: null,
message: "unexpected server error",
});
}
if (result.code === "ss002") {
res.status(201).json({
success: true,
code: "ss002",
data: null,
message: "",
});
}
});
router.post("/update-storage", authenticate, async (req, res) => {
const storageUUID = req.query.storageUUID;
const values = req.body;
const result = await updateStorage(storageUUID, values);
if (result.code === "es003") {
res.status(500).json({
success: false,
code: "es003",
data: null,
message: "unexpected server error",
});
}
if (result.code === "ss003") {
res.status(201).json({
success: true,
code: "ss003",
data: null,
message: "",
});
}
});
router.delete("/delete", authenticate, async (req, res) => {
const uuid = req.query.uuid;
const result = await deleteStorage(uuid);
if (result.code === "es004") {
res.status(500).json({
success: false,
code: "es004",
data: null,
message: "unexpected server error",
});
}
if (result.code === "ss004") {
res.status(201).json({
success: true,
code: "ss004",
data: null,
message: "",
});
}
});
export default router;
+189
View File
@@ -0,0 +1,189 @@
import express from "express";
import dotenv from "dotenv";
import { authenticate } from "../../services/tokenService";
import {
allStorages,
deleteStorage,
newStorage,
updateStorage,
} from "./database/storage.database";
import { GENERAL_ERROR_CODE, STORAGE_ERROR_CODE } from "@stockhome/shared";
dotenv.config();
const router = express.Router();
router.get("/all-storages", authenticate, async (req, res) => {
const result = await allStorages();
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR,
data: null,
message: "Unknown error",
});
}
if (result.code === "SS001") {
return res.status(200).json({
success: true,
code: result.code,
data: result.data,
message: result.message,
});
}
if (result.code === STORAGE_ERROR_CODE.NO_STORAGE_LOCATIONS_FOUND[0]) {
return res.status(404).json({
success: false,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.post("/new-storage", authenticate, async (req, res) => {
const { name, description } = req.body;
let desc = description;
if (!name) {
return res.status(400).json({
success: false,
code: STORAGE_ERROR_CODE.INVALID_REQUEST_BODY[0],
data: null,
message: STORAGE_ERROR_CODE.INVALID_REQUEST_BODY[1],
});
}
if (description == "") {
desc = null;
}
const result = await newStorage(name, desc);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
});
}
if (result.code === "SS002") {
return res.status(201).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.post("/update-storage", authenticate, async (req, res) => {
const storageUUID = req.query.storageUUID;
const values = req.body;
if (!storageUUID || !values) {
return res.status(400).json({
success: false,
code: STORAGE_ERROR_CODE.INVALID_PARAMETERS[0],
data: null,
message: STORAGE_ERROR_CODE.INVALID_PARAMETERS[1],
});
}
if (typeof storageUUID !== "string") {
return res.status(400).json({
success: false,
code: STORAGE_ERROR_CODE.INVALID_PARAMETERS[0],
data: null,
message: STORAGE_ERROR_CODE.INVALID_PARAMETERS[1],
});
}
const result = await updateStorage(storageUUID, values);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SS003") {
return res.status(201).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.delete("/delete", authenticate, async (req, res) => {
const uuid = req.query.uuid;
if (typeof uuid !== "string") {
return res.status(400).json({
success: false,
code: STORAGE_ERROR_CODE.INVALID_PARAMETERS[0],
data: null,
message: STORAGE_ERROR_CODE.INVALID_PARAMETERS[1],
});
}
const result = await deleteStorage(uuid);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SS004") {
return res.status(201).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
export default router;
-135
View File
@@ -1,135 +0,0 @@
import express from "express";
import dotenv from "dotenv";
import { authenticate, generateToken } from "../../services/tokenService.js";
import {
findUser,
loginUser,
updateSettings,
getSettings,
changePassword,
} from "./database/users.database.js";
dotenv.config();
const router = express.Router();
router.post("/verify-token", authenticate, async (req, res) => {
res.sendStatus(200);
});
router.post("/update-app-settings", authenticate, async (req, res) => {
const appName = req.body.appName;
const currency = req.body.currency;
const result = await updateSettings(req.body);
if (result.code === "su003") {
res.status(201).json({
success: true,
code: "su003",
data: result.data,
message: null,
});
}
if (result.code === "eu004") {
res.status(500).json({
success: false,
code: "eu004",
data: null,
message: "Unexpected server error",
});
}
});
router.get("/settings", authenticate, async (req, res) => {
const result = await getSettings();
if (result.code === "su004") {
res.status(201).json({
success: true,
code: "su004",
data: result.result,
message: null,
});
}
if (result.code === "eu005") {
res.status(500).json({
success: false,
code: "eu005",
data: null,
message: "Unexpected server error",
});
}
});
router.post("/login", async (req, res) => {
const username = req.body.username;
const password = req.body.password;
const result = await findUser(username, password);
if (result.code === "eu001") {
res.status(404).json({
success: false,
code: "eu001",
data: null,
message: "username oder password is wrong",
});
}
if (result.code === "eu002") {
res.status(403).json({
success: false,
code: "eu002",
data: null,
message: "user is deactivated",
});
}
if (result.code === "su001") {
const token = await generateToken(result.data);
const login = await loginUser(result.data.username);
if (login.code === "eu003") {
res.status(500).json({
success: false,
code: "eu003",
data: null,
message: "Unexpected server error. Please contact system admin.",
});
}
res.status(202).json({
success: true,
code: "su001",
data: {
token,
},
message: "User token generated successfully",
});
}
});
router.post("/change-password", authenticate, async (req, res) => {
const currentPassword = req.body.currentPassword;
const newPassword = req.body.newPassword;
const username = req.user.username;
const result = await changePassword(username, currentPassword, newPassword);
if (result.code === "su005") {
res.status(202).json({
success: true,
code: result.code,
});
}
if (result.code === "eu006") {
res.status(406).json({
success: false,
code: result.code,
});
}
});
export default router;
+251
View File
@@ -0,0 +1,251 @@
import express from "express";
import dotenv from "dotenv";
import { authenticate, generateToken } from "../../services/tokenService";
import {
changePassword,
findUser,
getSettings,
loginUser,
updateSettings,
} from "./database/users.database";
import { GENERAL_ERROR_CODE, USER_ERROR_CODE } from "@stockhome/shared";
dotenv.config();
const router = express.Router();
router.post("/verify-token", authenticate, async (req, res) => {
res.sendStatus(200);
});
router.post("/update-app-settings", authenticate, async (req, res) => {
const values = req.body;
if (!values || Object.keys(values).length === 0) {
return res.status(400).json({
success: false,
code: USER_ERROR_CODE.INVALID_REQUEST_BODY[0],
data: null,
message: USER_ERROR_CODE.INVALID_REQUEST_BODY[1],
});
}
const result = await updateSettings(values);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SU003") {
return res.status(201).json({
success: true,
code: result.code,
data: result.data,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.get("/settings", authenticate, async (req, res) => {
const result = await getSettings();
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SU004") {
return res.status(200).json({
success: true,
code: result.code,
data: result.data,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.post("/login", async (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
return res.status(400).json({
success: false,
code: USER_ERROR_CODE.INVALID_REQUEST_BODY[0],
data: null,
message: USER_ERROR_CODE.INVALID_REQUEST_BODY[1],
});
}
const result = await findUser(username, password);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === USER_ERROR_CODE.WRONG_USERNAME_PASSWORD[0]) {
return res.status(404).json({
success: false,
code: result.code,
data: null,
message: result.message,
});
}
if (result.code === USER_ERROR_CODE.USER_IS_DEACTIVATED[0]) {
return res.status(403).json({
success: false,
code: result.code,
data: null,
message: result.message,
});
}
if (result.code === "SU001") {
if (!result.data) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
const token = await generateToken(result.data);
if (!token) {
return res.status(500).json({
success: false,
code: USER_ERROR_CODE.TOKEN_GENERATION_FAILED[0],
data: null,
message: USER_ERROR_CODE.TOKEN_GENERATION_FAILED[1],
});
}
if (!result.data) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
const login = await loginUser(result.data.username);
if (!login) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (login.code !== "SU002") {
return res.status(500).json({
success: false,
code: login.code || USER_ERROR_CODE.LOGIN_FAILED[0],
data: null,
message: login.message || USER_ERROR_CODE.LOGIN_FAILED[1],
});
}
return res.status(202).json({
success: true,
code: "SU001",
data: {
token,
},
message: "User token generated successfully",
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
router.post("/change-password", authenticate, async (req, res) => {
const currentPassword = req.body.currentPassword;
const newPassword = req.body.newPassword;
if (!req.user) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
const username = req.user.username;
if (!currentPassword || !newPassword) {
return res.status(400).json({
success: false,
code: USER_ERROR_CODE.INVALID_REQUEST_BODY[0],
data: null,
message: USER_ERROR_CODE.INVALID_REQUEST_BODY[1],
});
}
const result = await changePassword(username, currentPassword, newPassword);
if (!result) {
return res.status(500).json({
success: false,
code: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
}
if (result.code === "SU005") {
return res.status(202).json({
success: true,
code: result.code,
data: null,
message: result.message,
});
}
return res.status(500).json({
success: false,
code: result.code || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[0],
data: null,
message: result.message || GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR[1],
});
});
export default router;
+17
View File
@@ -0,0 +1,17 @@
// scripts/copy-assets.cjs
const fs = require("fs");
const path = require("path");
const rootDir = path.join(__dirname, "..");
const distDir = path.join(rootDir, "dist");
const sqlFiles = fs
.readdirSync(rootDir)
.filter((f) => f.endsWith(".scheme.sql"));
for (const file of sqlFiles) {
const srcPath = path.join(rootDir, file);
const distPath = path.join(distDir, file);
fs.copyFileSync(srcPath, distPath);
console.log(`Copied ${srcPath} -> ${distPath}`);
}
-138
View File
@@ -1,138 +0,0 @@
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import mysql from "mysql2";
import { readFile } from "fs/promises";
dotenv.config();
const app = express();
app.set("view engine", "ejs");
const port = 8004;
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
// frontend routes
import userRouter from "./routes/app/users.route.js";
app.use("/users", userRouter);
import productRouter from "./routes/app/products.route.js";
app.use("/products", productRouter);
import storageRouter from "./routes/app/storage.route.js";
app.use("/storage", storageRouter);
app.listen(port, () => {
runStartup(port);
});
// Startup code
const runStartup = async (port) => {
// Check if database is configured; create schema if app_settings is missing.
let firstStartupValue = null;
try {
const [firstResponse] = await pool.query(
`SELECT value FROM app_settings WHERE name = "first-startup";`,
);
firstStartupValue = firstResponse[0]?.value ?? null;
} catch (err) {
if (err?.code !== "ER_NO_SUCH_TABLE") {
throw err;
}
}
if (firstStartupValue !== "false") {
const schemaPath = new URL("./database.scheme.sql", import.meta.url);
const schemaSql = await readFile(schemaPath, "utf8");
const statements = schemaSql
.split(";")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
for (const statement of statements) {
await pool.query(statement);
}
// create admin credentials
const [result] = await pool.query(
`SELECT value FROM app_settings WHERE name = "first-startup";`,
);
if (result[0]?.value === "true") {
const insertResult = await insertFirstData();
if (insertResult.affectedRows > 0) {
// print out admin credentials
console.log("Successfully created admin user!");
console.log("Username: admin");
console.log("Password: admin");
// Set startup variable to true if scheme insert was successfull
const [scndResponse] = await pool.query(
`UPDATE app_settings SET value = "false" WHERE name = "first-startup";`,
);
if (scndResponse.affectedRows > 0) {
console.log("Database settet up successfully!");
} else {
console.error("There was an error while setting up the database!");
}
} else {
console.error("Error while creating admin user.");
}
}
}
console.log("Everything is settet up successfully!");
console.log(`Backend is running on http://localhost:${port}`);
};
const insertFirstData = async () => {
const [insertResult] = await pool.query(
`INSERT INTO users (username, first_name, last_name, email, password, is_admin) VALUES ("admin", "admin", "admin", "admin@example.com", "admin", 1)`,
);
await pool.query(
`INSERT INTO storage_locations (name, description) VALUES (?, ?);`,
["Default Storage", "Initial storage location"],
);
const [storageRows] = await pool.query(
`SELECT uuid FROM storage_locations WHERE name = ? LIMIT 1;`,
["Default Storage"],
);
const storageUuid = storageRows[0]?.uuid ?? null;
if (storageUuid) {
await pool.query(
`INSERT INTO products (name, description, price, amount, storage_location, picture) VALUES (?, ?, ?, ?, ?, ?);`,
[
"Welcome Product",
"Your first item in Stockhome",
"0.00",
1,
storageUuid,
null,
],
);
}
console.log("Welcome product is ready...")
return insertResult;
};
// error handling code
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send("Something broke!");
});
+151
View File
@@ -0,0 +1,151 @@
import type { NextFunction, Request, Response } from "express";
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import mysql, { type ResultSetHeader, type RowDataPacket } from "mysql2";
import { readFile } from "fs/promises"; // frontend routes
import userRouter from "./routes/app/users.route";
import productRouter from "./routes/app/products.route";
import storageRouter from "./routes/app/storage.route";
import path from "node:path";
dotenv.config();
const app = express();
app.set("view engine", "ejs");
const port = 8004;
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const pool = mysql
.createPool({
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
})
.promise();
app.use("/users", userRouter);
app.use("/products", productRouter);
app.use("/storage", storageRouter);
app.listen(port, () => {
runStartup(port);
});
// Startup code
const runStartup = async (port: number) => {
let firstStartupValue: string | null = null;
try {
const [firstResponse] = await pool.query<RowDataPacket[]>(
`SELECT value
FROM app_settings
WHERE name = "first-startup";`,
);
firstStartupValue = firstResponse[0]?.value ?? null;
} catch (err: unknown) {
if (
err instanceof Error &&
(err as NodeJS.ErrnoException & { code?: string }).code !==
"ER_NO_SUCH_TABLE"
) {
throw err;
}
}
if (firstStartupValue !== "false") {
const schemaPath = path.join(__dirname, "database.scheme.sql");
const schemaSql = await readFile(schemaPath, "utf8");
const statements = schemaSql
.split(";")
.map((statement: string) => statement.trim())
.filter((statement: string) => statement.length > 0);
for (const statement of statements) {
await pool.query(statement);
}
const [result] = await pool.query<RowDataPacket[]>(
`SELECT value
FROM app_settings
WHERE name = "first-startup";`,
);
if (result[0]?.value === "true") {
const insertResult = await insertFirstData();
if (insertResult.affectedRows > 0) {
console.log("Successfully created admin user!");
console.log("Username: admin");
console.log("Password: admin");
const [scndResponse] = await pool.query<ResultSetHeader>(
`UPDATE app_settings
SET value = "false"
WHERE name = "first-startup";`,
);
if (scndResponse.affectedRows > 0) {
console.log("Database settet up successfully!");
} else {
console.error("There was an error while setting up the database!");
}
} else {
console.error("Error while creating admin user.");
}
}
}
console.log("Everything is settet up successfully!");
console.log(`Backend is running on http://localhost:${port}`);
};
const insertFirstData = async (): Promise<ResultSetHeader> => {
const [insertResult] = await pool.query<ResultSetHeader>(
`INSERT INTO users (username, first_name, last_name, email, password, is_admin)
VALUES ("admin", "admin", "admin", "admin@example.com", "admin", 1)`,
);
await pool.query(
`INSERT INTO storage_locations (name, description)
VALUES (?, ?);`,
["Default Storage", "Initial storage location"],
);
const [storageRows] = await pool.query<RowDataPacket[]>(
`SELECT uuid
FROM storage_locations
WHERE name = ? LIMIT 1;`,
["Default Storage"],
);
const storageUuid: string | null = storageRows[0]?.uuid ?? null;
if (storageUuid) {
await pool.query(
`INSERT INTO products (name, description, price, amount, storage_location, picture)
VALUES (?, ?, ?, ?, ?, ?);`,
[
"Welcome Product",
"Your first item in Stockhome",
"0.00",
1,
storageUuid,
null,
],
);
}
console.log("Welcome product is ready...");
return insertResult;
};
// error handling code
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).send("Something broke!");
});
+14
View File
@@ -0,0 +1,14 @@
import type { ErrorCode } from "../misc/types.ts";
export const returnErrorCode = (errorKey: ErrorCode) => {
if (!Array.isArray(errorKey) || errorKey.length < 2) {
return false;
}
return {
success: false,
code: errorKey[0],
data: null,
message: errorKey[1],
};
};
-25
View File
@@ -1,25 +0,0 @@
import {jwtVerify, SignJWT} from "jose";
import env from "dotenv";
env.config();
const secret = new TextEncoder().encode(process.env.SECRET_KEY);
export async function generateToken(payload) {
return await new SignJWT(payload)
.setProtectedHeader({alg: "HS256"})
.setIssuedAt()
.setExpirationTime("24h") // Token valid for 24 hours
.sign(secret);
}
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer <token>
if (token == null) return res.sendStatus(401); // No token present
const { payload } = await jwtVerify(token, secret);
req.user = payload;
next();
}
+40
View File
@@ -0,0 +1,40 @@
import type { NextFunction, Request, Response } from "express";
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
import env from "dotenv";
env.config();
const secret = new TextEncoder().encode(process.env.SECRET_KEY);
export type AuthTokenPayload = JWTPayload & {
username: string;
};
declare module "express-serve-static-core" {
interface Request {
user?: AuthTokenPayload;
}
}
export async function generateToken(payload: AuthTokenPayload) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(secret);
}
export async function authenticate(
req: Request,
res: Response,
next: NextFunction,
) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (token == null) return res.sendStatus(401);
const { payload } = await jwtVerify<AuthTokenPayload>(token, secret);
req.user = payload;
next();
}
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"composite": true,
"rootDir": ".",
"outDir": "./dist",
"module": "CommonJS",
"target": "ES2022",
"moduleResolution": "Node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"ignoreDeprecations": "6.0",
"types": ["node"],
"sourceMap": true,
"declaration": true,
"strict": true,
"skipLibCheck": true
},
"include": ["./**/*.ts"],
"exclude": [
"node_modules",
"dist",
"**/vite.config.ts",
"**/vite.config.*",
"**/*.config.ts",
"**/*.config.*"
],
"references": [
{
"path": "../shared"
}
]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import path from "path";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
root: "src",
build: {
outDir: "../dist",
emptyOutDir: true,
sourcemap: true,
minify: "esbuild",
assetsDir: "assets",
rollupOptions: {
input: path.resolve(__dirname, "src/index.html"),
output: {
assetFileNames: "assets/[name]-[hash][extname]",
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});
+6 -4
View File
@@ -11,7 +11,7 @@ services:
- ./.docker/volumes/stockhome_mysql:/var/lib/mysql - ./.docker/volumes/stockhome_mysql:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 20 retries: 20
@@ -19,8 +19,8 @@ services:
backend: backend:
container_name: stockhome-backend container_name: stockhome-backend
build: build:
context: ./backend context: .
dockerfile: Dockerfile dockerfile: backend/Dockerfile
ports: ports:
- "8004:8004" - "8004:8004"
environment: environment:
@@ -39,7 +39,9 @@ services:
container_name: stockhome-frontend container_name: stockhome-frontend
ports: ports:
- "80:80" - "80:80"
build: ./frontend build:
context: .
dockerfile: frontend/Dockerfile
environment: environment:
BACKEND_HOST: ${BACKEND_HOST} BACKEND_HOST: ${BACKEND_HOST}
depends_on: depends_on:
+13
View File
@@ -0,0 +1,13 @@
import js from "@eslint/js";
import globals from "globals";
export default [
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.node,
},
},
},
];
+12 -4
View File
@@ -3,17 +3,25 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
COPY backend/package.json backend/
COPY frontend/package.json frontend/
COPY shared/package.json shared/
RUN npm ci RUN npm ci
COPY . . COPY shared/ shared/
RUN npm run build COPY frontend/ frontend/
# Build shared first, then frontend
RUN npm run build --workspace=shared
RUN npm run build --workspace=frontend
FROM nginx:alpine AS runner FROM nginx:alpine AS runner
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
COPY --from=builder /app/dist . COPY --from=builder /app/frontend/dist .
COPY nginx.conf /etc/nginx/templates/default.conf.template COPY frontend/nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+11 -5
View File
@@ -4,12 +4,15 @@ This template provides a minimal setup to get React working in Vite with HMR and
Currently, two official plugins are available: Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc)
uses [SWC](https://swc.rs/)
## React Compiler ## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for
more information.
Note: This will impact Vite dev & build performances. Note: This will impact Vite dev & build performances.
@@ -45,10 +48,13 @@ export default defineConfig([
]) ])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: You can also
install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x)
and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom)
for React-specific lint rules:
```js ```js
// eslint.config.js // eslint.config.mjs
import reactX from 'eslint-plugin-react-x' import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom' import reactDom from 'eslint-plugin-react-dom'
-5116
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -16,8 +16,8 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/inter": "^5.2.8", "@fontsource/ibm-plex-mono": "^5.3.0",
"@mui/icons-material": "^9.0.1", "@fontsource/ibm-plex-sans": "^5.3.0",
"@mui/joy": "^5.0.0-beta.52", "@mui/joy": "^5.0.0-beta.52",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0", "@tanstack/react-form": "^1.32.0",
@@ -30,7 +30,6 @@
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8",
"react-toastify": "^11.1.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"validator": "^13.15.35", "validator": "^13.15.35",
"zod": "^4.4.3" "zod": "^4.4.3"
+6 -16
View File
@@ -1,9 +1,11 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"; import { createRouter, RouterProvider } from "@tanstack/react-router";
import "./App.css"; import "./App.css";
import { ToastContainer } from "react-toastify";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { NotFound } from "./components/NotFound.tsx";
import { CssVarsProvider } from "@mui/joy";
import { theme } from "./theme.ts";
const router = createRouter({ routeTree }); const router = createRouter({ routeTree, defaultNotFoundComponent: NotFound });
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
@@ -13,21 +15,9 @@ declare module "@tanstack/react-router" {
function App() { function App() {
return ( return (
<> <CssVarsProvider theme={theme} defaultMode={"system"}>
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick={false}
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
/>
<RouterProvider router={router} /> <RouterProvider router={router} />
</> </CssVarsProvider>
); );
} }
+65
View File
@@ -0,0 +1,65 @@
import { IconButton, Input } from "@mui/joy";
import { Minus, Plus } from "lucide-react";
interface AmountStepperProps {
value: number;
onChange: (value: number) => void;
onBlur?: () => void;
min?: number;
}
export const AmountStepper = ({
value,
onChange,
onBlur,
min = 0,
}: AmountStepperProps) => {
const step = (delta: number) => onChange(Math.max(min, (value || 0) + delta));
return (
<div
className="flex rounded-2xl border border-solid"
style={{ borderColor: "var(--joy-palette-divider)" }}
>
<IconButton
type="button"
variant="plain"
size="lg"
disabled={value <= min}
onClick={() => step(-1)}
className="flex-none transition-transform active:scale-90"
sx={{ borderRadius: 0 }}
>
<Minus size={16} />
</IconButton>
<Input
type="number"
value={value}
className="grow"
onChange={(e) => {
const nextValue = Number(e.target.value);
onChange(Number.isNaN(nextValue) ? min : Math.max(min, nextValue));
}}
onBlur={onBlur}
variant="plain"
size="lg"
slotProps={{ input: { className: "text-center font-semibold" } }}
sx={{
borderRadius: 0,
"--Input-focusedThickness": "0px",
bgcolor: "var(--joy-palette-background-surface)",
}}
/>
<IconButton
type="button"
variant="plain"
size="lg"
onClick={() => step(1)}
className="flex-none transition-transform active:scale-90"
sx={{ borderRadius: 0 }}
>
<Plus size={16} />
</IconButton>
</div>
);
};
+95 -24
View File
@@ -1,15 +1,21 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { Input, Button, Alert } from "@mui/joy"; import { Button, Input } from "@mui/joy";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { signInUser } from "../utils/api/auth"; import { signInUser } from "../utils/api/auth";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useColorScheme } from "@mui/joy/styles";
import { Layers } from "lucide-react";
import type { AlertInterface } from "../misc/interfaces"; import type { AlertInterface } from "../misc/interfaces";
import { changeTranslation } from "../utils/uxFncs";
import { MyAlert } from "./MyAlert.tsx";
export const LoginCard = () => { export const LoginCard = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { mode, setMode } = useColorScheme();
const search: { loggedOut?: boolean } = useSearch({ from: "/login" });
const [alert, setAlert] = useState<AlertInterface>({ const [alert, setAlert] = useState<AlertInterface>({
isAlert: false, isAlert: false,
type: "neutral", type: "neutral",
@@ -17,6 +23,18 @@ export const LoginCard = () => {
text: "", text: "",
}); });
useEffect(() => {
if (search.loggedOut) {
setAlert({
isAlert: true,
type: "primary",
header: t("success"),
text: t("logout-success-text"),
});
}
void navigate({ to: "/login" });
}, []);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
username: "", username: "",
@@ -43,7 +61,7 @@ export const LoginCard = () => {
header: "", header: "",
text: "", text: "",
}); });
navigate({ to: "/app/inventory" }); void navigate({ to: "/app/inventory" });
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
@@ -58,17 +76,59 @@ export const LoginCard = () => {
}); });
return ( return (
<div className="flex min-h-screen w-full items-center justify-center bg-[radial-gradient(1200px_circle_at_20%_10%,#e6f2ff_0%,#f7f9fc_40%,#f1f5fb_100%)] px-6 py-10"> <div className="flex min-h-screen w-full flex-col lg:flex-row">
<div className="mx-auto flex w-full max-w-4xl items-center justify-center"> <div
<div className="w-full max-w-md rounded-3xl border border-white/70 bg-white/80 p-8 shadow-[0_24px_60px_rgba(12,38,78,0.18)] backdrop-blur"> className="hidden flex-col justify-between px-12 py-10 lg:flex lg:w-1/2"
<div className="mb-8 space-y-2"> style={{ backgroundColor: "#0e1116", color: "#e8ecf2" }}
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-[#0b6bcb]"> >
Stockhome <div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: "#5b8def" }}>
<Layers size={16} color="white" />
</span>
<span className="text-lg font-bold">{t("app-title")}</span>
</div>
<div className="max-w-md space-y-3">
<h2 className="text-3xl font-bold" style={{ letterSpacing: "-0.03em" }}>
{t("login-tagline-1")}
</h2>
<p className="text-sm" style={{ color: "#8590a0" }}>
{t("login-tagline-2")}
</p> </p>
<h1 className="text-3xl font-semibold text-slate-900"> </div>
<p className="text-xs" style={{ color: "#8590a0" }}>
v0.9 MVP
</p>
</div>
<div
className="flex flex-1 items-center justify-center px-6 py-10"
style={{ backgroundColor: "var(--joy-palette-background-body)" }}
>
<div className="w-full max-w-md space-y-6">
<div className="flex items-center gap-2 lg:hidden">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg"
style={{ backgroundColor: "var(--joy-palette-primary-solidBg)" }}
>
<Layers size={16} color="white" />
</span>
<span className="text-lg font-bold" style={{ color: "var(--joy-palette-text-primary)" }}>
{t("app-title")}
</span>
</div>
<div className="space-y-2">
<p
className="text-xs font-semibold uppercase tracking-[0.3em]"
style={{ color: "var(--joy-palette-primary-solidBg)" }}
>
{t("app-title")}
</p>
<h1 className="text-3xl font-bold" style={{ color: "var(--joy-palette-text-primary)" }}>
{t("login")} {t("login")}
</h1> </h1>
</div> </div>
<form <form
className="space-y-5" className="space-y-5"
onSubmit={(e) => { onSubmit={(e) => {
@@ -77,15 +137,7 @@ export const LoginCard = () => {
}} }}
> >
{alert.isAlert && ( {alert.isAlert && (
<Alert <MyAlert type={alert.type} header={alert.header} text={alert.text} />
variant="soft"
color={alert.type}
className="rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]"
>
{alert.header}
<br />
{alert.text}
</Alert>
)} )}
<form.Field name="username"> <form.Field name="username">
{(field) => ( {(field) => (
@@ -95,7 +147,7 @@ export const LoginCard = () => {
placeholder={t("username")} placeholder={t("username")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
@@ -108,7 +160,7 @@ export const LoginCard = () => {
placeholder={t("password")} placeholder={t("password")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
@@ -116,11 +168,30 @@ export const LoginCard = () => {
type="submit" type="submit"
loading={isPending} loading={isPending}
size="lg" size="lg"
className="w-full rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]" color="primary"
variant="solid"
className="btn-lift w-full rounded-2xl"
> >
{t("login")} {t("login")}
</Button> </Button>
</form> </form>
<div
className="flex items-center justify-center gap-3 text-sm"
style={{ color: "var(--joy-palette-text-tertiary)" }}
>
<button type="button" onClick={changeTranslation} className="hover:underline">
{i18n.language === "de" ? "English" : "Deutsch"}
</button>
<span>|</span>
<button
type="button"
onClick={() => setMode(mode === "dark" ? "light" : "dark")}
className="hover:underline"
>
{mode === "dark" ? t("theme-light") : t("theme-dark")}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
interface MonoProps {
children: ReactNode;
className?: string;
}
// Small helper for the numeric/date values the mockups render in IBM Plex Mono.
export const Mono = ({ children, className = "" }: MonoProps) => (
<span
className={`tabular-nums ${className}`}
style={{ fontFamily: "var(--joy-fontFamily-code)" }}
>
{children}
</span>
);
+21
View File
@@ -0,0 +1,21 @@
import { Alert } from "@mui/joy";
interface MyAlertProps {
type: "success" | "warning" | "danger" | "neutral" | "primary";
header: string;
text: string;
}
export const MyAlert = (props: MyAlertProps) => {
return (
<Alert
variant="soft"
color={props.type}
className="animate-fade-in rounded-2xl drop-shadow-sm"
>
{props.header}
<br />
{props.text}
</Alert>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { Typography } from "@mui/joy";
import { useTranslation } from "react-i18next";
export const NotFound = () => {
const { t } = useTranslation();
return (
<div className={"flex flex-col items-center gap-2"}>
<Typography color={"primary"} level={"h1"}>
{t("not-found-header")}
</Typography>
<Typography
sx={{ color: "var(--joy-palette-text-primary)" }}
level={"body-lg"}
>
{t("not-found-body")}
</Typography>
</div>
);
};
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { Typography } from "@mui/joy";
interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}
export const PageHeader = ({ title, subtitle, actions }: PageHeaderProps) => (
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
{title}
</Typography>
{subtitle && (
<Typography level="body-lg" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{subtitle}
</Typography>
)}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
);
@@ -0,0 +1,43 @@
import { Chip } from "@mui/joy";
import { useTranslation } from "react-i18next";
import { getExpiryDays, type ProductStatus } from "../utils/productStatus";
const accentColor: Record<ProductStatus, string> = {
expired: "var(--joy-palette-danger-solidBg)",
"expiring-soon": "var(--joy-palette-warning-solidBg)",
ok: "transparent",
};
// Colored left-edge bar used on Inventory rows/cards to flag expiry status.
export const ProductStatusBar = ({ status }: { status: ProductStatus }) => (
<span
className="block h-full w-[3px] shrink-0 self-stretch rounded-full"
style={{ backgroundColor: accentColor[status] }}
/>
);
const chipColor: Record<ProductStatus, "danger" | "warning" | "neutral"> = {
expired: "danger",
"expiring-soon": "warning",
ok: "neutral",
};
// "Expires in N days" / "Expired N days ago" chip used on product detail headers.
export const ExpiryBadge = ({ expiryDate }: { expiryDate?: string | null }) => {
const { t } = useTranslation();
const expiry = getExpiryDays(expiryDate);
if (!expiry) {
return null;
}
const text =
expiry.status === "expired"
? t("days-since-expired", { count: Math.abs(expiry.days) })
: t("expires-in-days-badge", { count: expiry.days });
return (
<Chip variant="soft" color={chipColor[expiry.status]} size="sm" className="rounded-full px-3">
{text}
</Chip>
);
};
+102 -95
View File
@@ -1,67 +1,77 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Typography } from "@mui/joy"; import { Button, Chip, Typography } from "@mui/joy";
import InventoryIcon from "@mui/icons-material/Inventory"; import { Package, Plus, Layers, Settings, Menu, X, ChevronRight } from "lucide-react";
import AddBoxIcon from "@mui/icons-material/AddBox"; import { useNavigate, useRouterState } from "@tanstack/react-router";
import StorageIcon from "@mui/icons-material/Storage"; import { useQuery } from "@tanstack/react-query";
import SettingsIcon from "@mui/icons-material/Settings";
import ExitToAppIcon from "@mui/icons-material/ExitToApp";
import TranslateIcon from "@mui/icons-material/Translate";
import MenuIcon from "@mui/icons-material/Menu";
import CloseIcon from "@mui/icons-material/Close";
import { useNavigate, useMatchRoute } from "@tanstack/react-router";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { changeTranslation } from "../utils/uxFncs"; import { getProducts } from "../utils/api/products.ts";
import { getStorages } from "../utils/api/storages.ts";
export const Sidebar = () => { export const Sidebar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const matchRoute = useMatchRoute(); const pathname = useRouterState({ select: (state) => state.location.pathname });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const btnClass = const { data: products } = useQuery({ queryKey: ["products"], queryFn: getProducts });
"h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold text-slate-700 transition hover:bg-white/80 hover:text-[#0b6bcb] [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0!"; const { data: storages } = useQuery({ queryKey: ["storages"], queryFn: getStorages });
const variant = (to: string) => const btnClass =
!!matchRoute({ to, fuzzy: false }) ? "soft" : "plain"; "h-11 w-full justify-start! rounded-2xl px-4 text-left text-sm font-semibold transition-colors duration-150 [&_.MuiButton-startDecorator]:mr-3! [&_.MuiButton-startDecorator]:ml-0! [&_.MuiButton-endDecorator]:ml-auto!";
const isActive = (to: string) => pathname === to || pathname.startsWith(`${to}/`);
const handleNavigate = (to: string) => { const handleNavigate = (to: string) => {
navigate({ to }); void navigate({ to });
setIsOpen(false); setIsOpen(false);
}; };
const appName = Cookies.get("app-name") || t("app-title");
const initials = appName.slice(0, 2).toUpperCase();
const navItems = [
{ to: "/app/inventory", label: t("inventory"), icon: <Package size={18} />, count: products?.length },
{ to: "/app/add-product", label: t("add"), icon: <Plus size={18} /> },
{ to: "/app/storages", label: t("storages"), icon: <Layers size={18} />, count: storages?.length },
{ to: "/app/app-settings", label: t("settings"), icon: <Settings size={18} /> },
];
return ( return (
<aside className="flex w-full flex-col gap-6 border-b border-white/80 bg-linear-to-b from-[#f7fbff] via-[#f2f6fb] to-[#eef3f9] px-4 py-5 shadow-[0_20px_60px_rgba(11,107,203,0.08)] sm:px-5 lg:h-full lg:min-h-screen lg:max-w-70 lg:border-b-0 lg:border-r lg:px-6 lg:py-8"> <aside
className="flex w-full flex-col gap-6 px-4 py-5 sm:px-5 lg:h-full lg:min-h-screen lg:max-w-70 lg:border-b-0 lg:border-r lg:px-6 lg:py-8"
style={{
borderBottom: "1px solid var(--joy-palette-divider)",
borderRightColor: "var(--joy-palette-divider)",
backgroundColor: "var(--joy-palette-background-surface)",
}}
>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="space-y-2"> <div className="flex items-center gap-2">
<span
className="flex h-8 w-8 items-center justify-center rounded-lg"
style={{ backgroundColor: "var(--joy-palette-primary-solidBg)" }}
>
<Layers size={16} color="white" />
</span>
<Typography <Typography
level="h2" level="title-lg"
className="text-[22px] font-semibold text-[#0b6bcb]" className="text-[17px] font-bold"
sx={{ color: "var(--joy-palette-text-primary)" }}
> >
{t("app-title")} {t("app-title")}
</Typography> </Typography>
<Typography
level="body-lg"
className="text-sm font-medium text-slate-500"
>
{Cookies.get("app-name") ? Cookies.get("app-name") : ""}
</Typography>
</div> </div>
<Button <Button
variant="soft" variant="soft"
color="neutral"
size="sm" size="sm"
className="lg:hidden transition-transform duration-200 ease-out" sx={{
display: "inline-flex",
"@media (min-width: 1024px)": { display: "none" },
}}
onClick={() => setIsOpen((open) => !open)} onClick={() => setIsOpen((open) => !open)}
startDecorator={ startDecorator={isOpen ? <X size={16} /> : <Menu size={16} />}
<span
className={`inline-flex transition-transform duration-200 ease-out ${
isOpen ? "rotate-180" : "rotate-0"
}`}
>
{isOpen ? <CloseIcon /> : <MenuIcon />}
</span>
}
sx={{ display: { lg: "none" } }}
> >
{isOpen ? t("close") : t("menu")} {isOpen ? t("close") : t("menu")}
</Button> </Button>
@@ -74,71 +84,68 @@ export const Sidebar = () => {
: "max-h-0 opacity-0 pointer-events-none" : "max-h-0 opacity-0 pointer-events-none"
} lg:max-h-none lg:opacity-100 lg:pointer-events-auto`} } lg:max-h-none lg:opacity-100 lg:pointer-events-auto`}
> >
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-1">
{navItems.map((item) => {
const active = isActive(item.to);
return (
<Button <Button
onClick={() => handleNavigate("/app/inventory")} key={item.to}
variant={variant("/app/inventory")} onClick={() => handleNavigate(item.to)}
startDecorator={<InventoryIcon />} variant={active ? "soft" : "plain"}
color="primary"
startDecorator={item.icon}
endDecorator={
item.count !== undefined ? (
<Chip size="sm" variant="soft" color={active ? "primary" : "neutral"}>
{item.count}
</Chip>
) : undefined
}
className={btnClass} className={btnClass}
> sx={{
{t("inventory")} color: active
</Button> ? "var(--joy-palette-primary-solidBg)"
<Button : "var(--joy-palette-text-secondary)",
onClick={() => handleNavigate("/app/add-product")} borderLeft: "3px solid",
variant={variant("/app/add-product")} borderLeftColor: active ? "var(--joy-palette-primary-solidBg)" : "transparent",
startDecorator={<AddBoxIcon />} "&:hover": {
className={btnClass} bgcolor: active
> ? "var(--joy-palette-primary-softBg)"
{t("add")} : "var(--joy-palette-background-level1)",
</Button> },
<Button
onClick={() => handleNavigate("/app/storages")}
variant={variant("/app/storages")}
startDecorator={<StorageIcon />}
className={btnClass}
>
{t("storages")}
</Button>
<Button
onClick={() => handleNavigate("/app/app-settings")}
variant={variant("/app/app-settings")}
startDecorator={<SettingsIcon />}
className={btnClass}
>
{t("settings")}
</Button>
<Button
onClick={() => {
Cookies.remove("token");
handleNavigate("/login");
}} }}
color="danger"
startDecorator={<ExitToAppIcon />}
className={btnClass}
> >
{t("logout")} {item.label}
</Button>
<Button
onClick={() => {
changeTranslation();
setIsOpen(false);
}}
color="neutral"
startDecorator={<TranslateIcon />}
className={btnClass}
>
{t("change-translation")}
</Button> </Button>
);
})}
</div> </div>
<div className="flex items-center gap-3 rounded-2xl border border-white/70 bg-white/80 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-[#0b6bcb] shadow-[0_12px_30px_rgba(12,38,78,0.12)]"> <button
<img type="button"
src="/favicon.png" onClick={() => handleNavigate("/app/app-settings")}
alt="Stockhome" className="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition"
className="h-7 w-7 rounded-lg" style={{
/> border: "1px solid var(--joy-palette-divider)",
<span>Stockhome</span> backgroundColor: "var(--joy-palette-background-level1)",
}}
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor: "var(--joy-palette-primary-softBg)",
color: "var(--joy-palette-primary-softColor)",
}}
>
{initials}
</span>
<div className="min-w-0 flex-1">
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
{appName}
</Typography>
</div> </div>
<ChevronRight size={16} color="var(--joy-palette-text-tertiary)" />
</button>
</div> </div>
</aside> </aside>
); );
+53
View File
@@ -0,0 +1,53 @@
import { Typography } from "@mui/joy";
import { useTranslation } from "react-i18next";
interface StatTilesProps {
expired: number;
expiringSoon: number;
lowStock: number;
unitsStored: number;
}
export const StatTiles = ({
expired,
expiringSoon,
lowStock,
unitsStored,
}: StatTilesProps) => {
const { t } = useTranslation();
const tiles = [
{ value: expired, label: t("stat-expired"), accent: "var(--joy-palette-danger-solidBg)" },
{ value: expiringSoon, label: t("stat-expiring-soon"), accent: "var(--joy-palette-warning-solidBg)" },
{ value: lowStock, label: t("stat-low-stock"), accent: "var(--joy-palette-neutral-500)" },
{ value: unitsStored, label: t("stat-units-stored"), accent: "var(--joy-palette-primary-solidBg)" },
];
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{tiles.map((tile) => (
<div
key={tile.label}
className="flex items-center gap-3 rounded-2xl border border-solid px-4 py-4"
style={{
borderColor: "var(--joy-palette-divider)",
backgroundColor: "var(--joy-palette-background-surface)",
}}
>
<span
className="h-9 w-[3px] shrink-0 rounded-full"
style={{ backgroundColor: tile.accent }}
/>
<div>
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
{tile.value}
</Typography>
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{tile.label}
</Typography>
</div>
</div>
))}
</div>
);
};
+62 -22
View File
@@ -1,11 +1,14 @@
import { useQueryClient, useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { deleteStorage, updateStorage } from "../utils/api/storages"; import { deleteStorage, updateStorage } from "../utils/api/storages";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useStore } from "@tanstack/react-store"; import { Button, IconButton, Input } from "@mui/joy";
import { Input, Button } from "@mui/joy"; import { Trash2 } from "lucide-react";
import type { Storage } from "../misc/interfaces"; import type { Storage } from "../misc/interfaces";
import { formatDate } from "../utils/uxFncs"; import { formatDate } from "../utils/uxFncs";
import { Mono } from "./Mono.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react";
import { DeleteConfirmation } from "./modals/DeleteConfirmation.tsx";
interface StorageRowProps { interface StorageRowProps {
storage: Storage; storage: Storage;
@@ -15,6 +18,9 @@ interface StorageRowProps {
export const StorageRow = ({ storage, onError }: StorageRowProps) => { export const StorageRow = ({ storage, onError }: StorageRowProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showModal, setShowModal] = useState<boolean>(false);
const [storageToDelete, setStorageToDelete] = useState<Storage | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
@@ -26,6 +32,11 @@ export const StorageRow = ({ storage, onError }: StorageRowProps) => {
onError, onError,
}); });
const deleteStorageModalFnc = async (storage: Storage) => {
setStorageToDelete(storage);
setShowModal(true);
};
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (uuid: string) => deleteStorage(uuid), mutationFn: (uuid: string) => deleteStorage(uuid),
onSuccess: () => { onSuccess: () => {
@@ -44,13 +55,15 @@ export const StorageRow = ({ storage, onError }: StorageRowProps) => {
}, },
}); });
const values = useStore(form.baseStore, (state) => state.values);
const isDirty =
values.name !== storage.name ||
(values.description ?? "") !== (storage.description ?? "");
return ( return (
<tr key={storage.uuid} className="border-t border-slate-200 align-top"> <>
<DeleteConfirmation
open={showModal}
setOpen={setShowModal}
storage={storageToDelete}
deleteStorageByUUID={(uuid) => deleteMutation.mutateAsync(uuid)}
/>
<tr key={storage.uuid} className="align-top">
<td className="px-6 py-5"> <td className="px-6 py-5">
<form.Field name="name"> <form.Field name="name">
{(field) => ( {(field) => (
@@ -60,7 +73,12 @@ export const StorageRow = ({ storage, onError }: StorageRowProps) => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="sm" size="sm"
variant="outlined" variant="outlined"
className="rounded-xl bg-slate-50/80 text-slate-700 shadow-none ring-1 ring-inset ring-slate-200 focus-within:ring-2 focus-within:ring-slate-300" className="rounded-xl shadow-none"
sx={{
bgcolor: "var(--joy-palette-background-level1)",
color: "var(--joy-palette-text-secondary)",
"--Input-focusedHighlight": "var(--joy-palette-neutral-300)",
}}
/> />
)} )}
</form.Field> </form.Field>
@@ -74,41 +92,63 @@ export const StorageRow = ({ storage, onError }: StorageRowProps) => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="sm" size="sm"
variant="outlined" variant="outlined"
className="rounded-xl bg-slate-50/80 text-slate-700 shadow-none ring-1 ring-inset ring-slate-200 focus-within:ring-2 focus-within:ring-slate-300" className="rounded-xl shadow-none"
sx={{
bgcolor: "var(--joy-palette-background-level1)",
color: "var(--joy-palette-text-secondary)",
"--Input-focusedHighlight": "var(--joy-palette-neutral-300)",
}}
/> />
)} )}
</form.Field> </form.Field>
</td> </td>
<td className="px-6 py-5 text-sm text-slate-500"> <td
{formatDate(storage.created_at)} className="px-6 py-5 text-sm"
style={{ color: "var(--joy-palette-text-tertiary)" }}
>
<Mono>{formatDate(storage.created_at)}</Mono>
</td> </td>
<td className="px-6 py-5 text-sm text-slate-500"> <td
{formatDate(storage.updated_at)} className="px-6 py-5 text-sm"
style={{ color: "var(--joy-palette-text-tertiary)" }}
>
<Mono>{formatDate(storage.updated_at)}</Mono>
</td> </td>
<td className="px-6 py-5 text-right"> <td className="px-6 py-5 text-right">
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<form.Subscribe
selector={(state) =>
state.values.name !== storage.name ||
(state.values.description ?? "") !== (storage.description ?? "")
}
>
{(isDirty) =>
isDirty && (
<Button <Button
color="primary" color="primary"
onClick={form.handleSubmit} onClick={form.handleSubmit}
disabled={ disabled={mutation.isPending || deleteMutation.isPending}
!isDirty || mutation.isPending || deleteMutation.isPending
}
size="sm" size="sm"
className="rounded-xl" className="rounded-xl"
> >
{mutation.isPending ? "..." : t("save")} {mutation.isPending ? "..." : t("save")}
</Button> </Button>
<Button )
}
</form.Subscribe>
<IconButton
color="danger" color="danger"
onClick={() => deleteMutation.mutateAsync(storage.uuid)} variant="plain"
onClick={() => deleteStorageModalFnc(storage)}
disabled={mutation.isPending || deleteMutation.isPending} disabled={mutation.isPending || deleteMutation.isPending}
size="sm" size="sm"
className="rounded-xl" className="rounded-xl"
> >
{deleteMutation.isPending ? "..." : t("delete")} <Trash2 size={16} />
</Button> </IconButton>
</div> </div>
</td> </td>
</tr> </tr>
</>
); );
}; };
@@ -1,19 +1,11 @@
import { import { Button, DialogContent, DialogTitle, Input, Modal, ModalDialog, Stack, } from "@mui/joy";
Modal,
ModalDialog,
DialogTitle,
DialogContent,
Stack,
Input,
Button,
Alert,
} from "@mui/joy";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { NewStorage, AlertInterface } from "../../misc/interfaces"; import type { AlertInterface, NewStorage } from "../../misc/interfaces";
import { mutateNewStorage } from "../../utils/api/storages"; import { mutateNewStorage } from "../../utils/api/storages";
import { useState } from "react"; import { useState } from "react";
import { MyAlert } from "../MyAlert.tsx";
interface AddStorageModalProps { interface AddStorageModalProps {
isOpen: boolean; isOpen: boolean;
@@ -59,11 +51,18 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
return ( return (
<> <>
<Modal open={props.isOpen} onClose={() => props.setOpen(false)}> <Modal open={props.isOpen} onClose={() => props.setOpen(false)}>
<ModalDialog className="rounded-3xl border border-white/70 bg-white/90 p-6 shadow-[0_30px_70px_rgba(12,38,78,0.2)] backdrop-blur"> <ModalDialog
<DialogTitle className="text-slate-900"> className="rounded-3xl p-6"
sx={{
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
}}
>
<DialogTitle sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("new-storage-title")} {t("new-storage-title")}
</DialogTitle> </DialogTitle>
<DialogContent className="text-slate-500"> <DialogContent sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("new-storage-content")} {t("new-storage-content")}
</DialogContent> </DialogContent>
<form <form
@@ -81,7 +80,7 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
placeholder={t("storage-name")} placeholder={t("storage-name")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
@@ -93,29 +92,27 @@ export const AddStorageModal = (props: AddStorageModalProps) => {
placeholder={t("description")} placeholder={t("description")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]" color="primary"
variant="solid"
className="btn-lift rounded-2xl"
> >
{t("submit")} {t("submit")}
</Button> </Button>
</Stack> </Stack>
</form> </form>
{alert.isAlert && ( {alert.isAlert && (
<Alert <MyAlert
variant="soft" type={alert.type}
color={alert.type} header={alert.header}
className="mt-4 rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]" text={alert.text}
> />
{alert.header}
<br />
{alert.text}
</Alert>
)} )}
</ModalDialog> </ModalDialog>
</Modal> </Modal>
@@ -1,22 +1,24 @@
import { import {
Button,
DialogTitle,
Input,
Modal, Modal,
ModalDialog, ModalDialog,
DialogTitle,
Stack, Stack,
Input,
Button,
Alert,
} from "@mui/joy"; } from "@mui/joy";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ChangePasswordIntf, AlertInterface } from "../../misc/interfaces"; import type { AlertInterface, ChangePasswordIntf } from "../../misc/interfaces";
import { mutatePassword } from "../../utils/api/auth"; import { mutatePassword } from "../../utils/api/auth";
import { useState } from "react"; import { useState } from "react";
import { USER_ERROR_CODE } from "@stockhome/shared";
import { MyAlert } from "../MyAlert.tsx";
interface ChangePasswordProps { interface ChangePasswordProps {
isOpen: boolean; isOpen: boolean;
setOpen: (value: boolean) => void; setOpen: (value: boolean) => void;
alert: (alert: AlertInterface) => void;
} }
export const ChangePasswordModal = (props: ChangePasswordProps) => { export const ChangePasswordModal = (props: ChangePasswordProps) => {
@@ -35,7 +37,19 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
newPasswordRep: "", newPasswordRep: "",
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
if (
value.newPassword === value.newPasswordRep &&
value.newPassword !== ""
) {
mutate(value); mutate(value);
} else {
setAlert({
isAlert: true,
type: "danger",
header: t("error"),
text: t(USER_ERROR_CODE.PASSWORDS_NOT_MATCHED[0]),
});
}
}, },
}); });
@@ -50,6 +64,19 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
props.alert({
isAlert: true,
type: "success",
header: t("success"),
text: t("SU005"),
});
setAlert({
isAlert: true,
type: "success",
header: t("success"),
text: t("SU005"),
});
props.setOpen(false); props.setOpen(false);
}, },
}); });
@@ -57,8 +84,15 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
return ( return (
<> <>
<Modal open={props.isOpen} onClose={() => props.setOpen(false)}> <Modal open={props.isOpen} onClose={() => props.setOpen(false)}>
<ModalDialog className="rounded-3xl border border-white/70 bg-white/90 p-6 shadow-[0_30px_70px_rgba(12,38,78,0.2)] backdrop-blur"> <ModalDialog
<DialogTitle className="text-slate-900"> className="rounded-3xl p-6"
sx={{
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
}}
>
<DialogTitle sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("new-password-title")} {t("new-password-title")}
</DialogTitle> </DialogTitle>
<form <form
@@ -77,7 +111,7 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
placeholder={t("current-password")} placeholder={t("current-password")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
@@ -90,7 +124,7 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
placeholder={t("new-password")} placeholder={t("new-password")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
@@ -103,29 +137,27 @@ export const ChangePasswordModal = (props: ChangePasswordProps) => {
placeholder={t("new-password-rep")} placeholder={t("new-password-rep")}
variant="outlined" variant="outlined"
size="lg" size="lg"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]" color="primary"
variant="solid"
className="btn-lift rounded-2xl"
> >
{t("change")} {t("change")}
</Button> </Button>
</Stack> </Stack>
</form> </form>
{alert.isAlert && ( {alert.isAlert && (
<Alert <MyAlert
variant="soft" type={alert.type}
color={alert.type} header={alert.header}
className="mt-4 rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]" text={alert.text}
> />
{alert.header}
<br />
{alert.text}
</Alert>
)} )}
</ModalDialog> </ModalDialog>
</Modal> </Modal>
@@ -0,0 +1,54 @@
import Button from "@mui/joy/Button";
import Divider from "@mui/joy/Divider";
import DialogTitle from "@mui/joy/DialogTitle";
import DialogContent from "@mui/joy/DialogContent";
import DialogActions from "@mui/joy/DialogActions";
import Modal from "@mui/joy/Modal";
import ModalDialog from "@mui/joy/ModalDialog";
import type { Storage } from "../../misc/interfaces";
import { useTranslation } from "react-i18next";
interface DeleteConfirmationProps {
open: boolean;
setOpen: (open: boolean) => void;
storage: Storage | null;
deleteStorageByUUID: (uuid: string) => void;
}
export const DeleteConfirmation = (props: DeleteConfirmationProps) => {
const { t } = useTranslation();
return (
<Modal open={props.open} onClose={() => props.setOpen(false)}>
<ModalDialog variant="outlined" role="alertdialog">
<DialogTitle>Confirmation</DialogTitle>
<Divider />
<DialogContent>
{t("delete-confirmation-text")}
{props.storage ? ` "${props.storage.name}"` : ""}?
</DialogContent>
<DialogActions>
<Button
variant="solid"
color="danger"
onClick={() => {
if (props.storage) {
props.deleteStorageByUUID(props.storage.uuid);
}
props.setOpen(false);
}}
>
{t("delete")}
</Button>
<Button
variant="plain"
color="neutral"
onClick={() => props.setOpen(false)}
>
{t("cancel")}
</Button>
</DialogActions>
</ModalDialog>
</Modal>
);
};
+13
View File
@@ -0,0 +1,13 @@
import Cookies from "js-cookie";
import { useNavigate } from "@tanstack/react-router";
export const useLogout = () => {
const navigate = useNavigate();
const logout = () => {
Cookies.remove("token");
void navigate({ to: "/login", search: { loggedOut: true } });
};
return { logout };
};
+53
View File
@@ -1 +1,54 @@
@import "tailwindcss"; @import "tailwindcss";
@keyframes page-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-page-in {
animation: page-in 220ms ease-out;
}
.animate-fade-in {
animation: fade-in 180ms ease-out;
}
.btn-lift {
transition:
transform 150ms ease,
box-shadow 150ms ease;
}
.btn-lift:hover {
transform: translateY(-1px);
}
.btn-lift:active {
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.animate-page-in,
.animate-fade-in,
.btn-lift {
animation: none !important;
transition: none !important;
}
}
+5
View File
@@ -1,5 +1,10 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "@fontsource/ibm-plex-sans/400.css";
import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import "@fontsource/ibm-plex-sans/700.css";
import "@fontsource/ibm-plex-mono/500.css";
import "./index.css"; import "./index.css";
import "./utils/i18n"; import "./utils/i18n";
import App from "./App.tsx"; import App from "./App.tsx";
+1
View File
@@ -59,6 +59,7 @@ export type ProductRow = {
location: string; location: string;
locationDetail: string; locationDetail: string;
expiryDate: string; expiryDate: string;
expiryDateRaw: string | null;
refillDate: string; refillDate: string;
}; };
+90 -135
View File
@@ -1,31 +1,25 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import { Button, Input, Option, Select, Typography } from "@mui/joy";
Alert,
Box,
Button,
Chip,
Divider,
Input,
Option,
Select,
Typography,
} from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { createProduct } from "../utils/api/products"; import { createProduct } from "../utils/api/products";
import { getStorages } from "../utils/api/storages"; import { getStorages } from "../utils/api/storages";
import type { import type { AlertInterface, ProductFormValues, Storage } from "../misc/interfaces";
ProductFormValues,
AlertInterface,
Storage,
} from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError"; import type { ApiError } from "../utils/api/apiError";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { AmountStepper } from "../components/AmountStepper.tsx";
import { PageHeader } from "../components/PageHeader.tsx";
import { MyAlert } from "../components/MyAlert.tsx";
const cardSx = {
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
} as const;
export const AddProduct = () => { export const AddProduct = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [success, setSuccess] = useState(false);
const [alert, setAlert] = useState<AlertInterface>({ const [alert, setAlert] = useState<AlertInterface>({
isAlert: false, isAlert: false,
type: "neutral", type: "neutral",
@@ -69,7 +63,6 @@ export const AddProduct = () => {
storage_location_uuid: "", storage_location_uuid: "",
}, },
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
setSuccess(false);
mutate(value); mutate(value);
}, },
}); });
@@ -77,44 +70,34 @@ export const AddProduct = () => {
const { mutate, isPending } = useMutation({ const { mutate, isPending } = useMutation({
mutationFn: (values: ProductFormValues) => createProduct(values), mutationFn: (values: ProductFormValues) => createProduct(values),
onSuccess: () => { onSuccess: () => {
setSuccess(true); setAlert({
isAlert: true,
type: "success",
header: "",
text: "",
});
}, },
onError: showError, onError: showError,
}); });
return ( return (
<>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center gap-3"> <PageHeader title={t("add-product")} subtitle={t("add-product-subtitle")} />
<div className="space-y-1">
<Typography level="h2" className="text-slate-900">
{t("add-product")}
</Typography>
<Typography level="body-lg" className="text-slate-500">
{t("add-product-subtitle")}
</Typography>
</div>
<Chip
variant="soft"
color="primary"
className="ml-auto rounded-full px-3"
>
{t("details")}
</Chip>
</div>
</div>
<Box className="mt-6 rounded-3xl border border-white/70 bg-white/80 p-6 shadow-[0_24px_60px_rgba(12,38,78,0.12)] backdrop-blur">
<form <form
className="space-y-6"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
form.handleSubmit(); form.handleSubmit();
}} }}
> >
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-4"> <div className="rounded-3xl p-6" style={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("product")}
</Typography>
<div className="mt-4 space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("product-name")} {t("product-name")}
</Typography> </Typography>
<form.Field name="name"> <form.Field name="name">
@@ -122,55 +105,39 @@ export const AddProduct = () => {
<Input <Input
type="text" type="text"
required required
placeholder={t("product-name-placeholder")}
value={field.state.value} value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("description")} {t("description")}
</Typography> </Typography>
<form.Field name="description"> <form.Field name="description">
{(field) => ( {(field) => (
<Input <Input
type="text" type="text"
placeholder={t("description-placeholder")}
value={field.state.value} value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("expiry-date")}
</Typography>
<form.Field name="expiry_date">
{(field) => (
<Input
type="date"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90"
/>
)}
</form.Field>
</div>
<div className="space-y-1">
<Typography level="title-md" className="text-slate-900">
{t("bottling-date")} {t("bottling-date")}
</Typography> </Typography>
<form.Field name="bottling_date"> <form.Field name="bottling_date">
@@ -182,48 +149,54 @@ export const AddProduct = () => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90" className="rounded-2xl"
/>
)}
</form.Field>
</div>
<div className="space-y-1">
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("expiry-date")}
</Typography>
<form.Field name="expiry_date">
{(field) => (
<Input
type="date"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-4"> </div>
<div className="rounded-2xl border border-white/70 bg-linear-to-br from-[#f7fbff] via-[#f2f6fb] to-[#eef3f9] p-5 shadow-[0_16px_40px_rgba(12,38,78,0.08)]">
<Typography level="title-md" className="text-slate-900"> <div className="rounded-3xl p-6" style={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("inventory")} {t("inventory")}
</Typography> </Typography>
<Divider className="my-3" /> <div className="mt-4 space-y-4">
<div className="grid gap-4">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("amount")} {t("amount")}
</Typography> </Typography>
<form.Field name="amount"> <form.Field name="amount">
{(field) => ( {(field) => (
<Input <AmountStepper
type="number"
color="neutral"
id="amountInput"
placeholder={t("amount")}
value={field.state.value} value={field.state.value}
variant="soft" onChange={field.handleChange}
size="lg"
onChange={(e) => {
const nextValue = Number(e.target.value);
field.handleChange(
Number.isNaN(nextValue) ? 0 : nextValue,
);
}}
onBlur={field.handleBlur} onBlur={field.handleBlur}
className="rounded-2xl bg-white/80"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("price")} {t("price")}
</Typography> </Typography>
<form.Field name="price"> <form.Field name="price">
@@ -235,29 +208,30 @@ export const AddProduct = () => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90" className="rounded-2xl"
endDecorator={
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{Cookies.get("currency")}
</Typography>
}
/> />
)} )}
</form.Field> </form.Field>
<Typography level="body-sm" className="text-slate-500">
{Cookies.get("currency")}
</Typography>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("storage-place")} {t("storage-place")}
</Typography> </Typography>
<form.Field name="storage_location_uuid"> <form.Field name="storage_location_uuid">
{(field) => ( {(field) => (
<Select <Select
required required
placeholder={t("choose-storage")}
value={field.state.value} value={field.state.value}
onChange={(_event, value) => onChange={(_event, value) => field.handleChange(value ?? "")}
field.handleChange(value ?? "")
}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90" className="rounded-2xl"
> >
{storages?.map((storage) => ( {storages?.map((storage) => (
<Option key={storage.uuid} value={storage.uuid}> <Option key={storage.uuid} value={storage.uuid}>
@@ -268,49 +242,30 @@ export const AddProduct = () => {
)} )}
</form.Field> </form.Field>
</div> </div>
</div> <Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
</div> {t("amount-price-optional-hint")}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<Typography level="body-sm" className="text-slate-500">
{t("product-details")}
</Typography> </Typography>
<Button </div>
type="submit" </div>
loading={isPending} </div>
size="lg"
className="rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]" <div className="mt-5 flex flex-wrap items-center justify-between gap-3">
> <Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("save")} {t("all-fields-editable-later")}
</Typography>
<div className="flex gap-3">
<Button type="submit" loading={isPending} size="lg" color="primary" variant="solid" className="btn-lift rounded-2xl">
{t("save-product")}
</Button> </Button>
</div> </div>
{alert.isAlert && (
<Alert
color={alert.type}
variant="soft"
className="rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]"
>
{alert.header}
<br />
{alert.text}
</Alert>
)}
{success && (
<Alert
color="success"
variant="soft"
className="rounded-2xl border border-emerald-200/70 bg-emerald-50/80 text-emerald-700 shadow-[0_14px_30px_rgba(16,185,129,0.18)]"
>
<div className="flex w-full items-center justify-between">
<Typography level="body-sm" className="text-emerald-700">
{t("success")}
</Typography>
</div> </div>
</Alert>
{alert.isAlert && (
<div className="mt-4">
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
</div>
)} )}
</form> </form>
</Box> </div>
</>
); );
}; };
+342 -150
View File
@@ -1,25 +1,53 @@
import { useEffect, useState } from "react"; import { ChangeEvent, Fragment, MouseEvent, useEffect, useMemo, useState } from "react";
import { import { Button, Checkbox, Chip, CircularProgress, Input, Sheet, Table, Typography } from "@mui/joy";
Typography,
Button,
CircularProgress,
Sheet,
Table,
Avatar,
Chip,
Checkbox,
Alert,
} from "@mui/joy";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add"; import { ChevronDown, Plus, Search, Trash2 } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { deleteSelectedProducts, getProducts } from "../utils/api/products"; import { deleteSelectedProducts, getProducts } from "../utils/api/products";
import { formatDate } from "../utils/uxFncs"; import { formatDate } from "../utils/uxFncs";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import type { ProductRow } from "../misc/interfaces"; import type { AlertInterface, ProductRow } from "../misc/interfaces";
import type { AlertInterface } from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError"; import type { ApiError } from "../utils/api/apiError";
import { MyAlert } from "../components/MyAlert.tsx";
import { PageHeader } from "../components/PageHeader.tsx";
import { StatTiles } from "../components/StatTiles.tsx";
import { Mono } from "../components/Mono.tsx";
import { ProductStatusBar } from "../components/ProductStatusBar.tsx";
import { getExpiryDays, getProductStatus, isExpired, isExpiringSoon, isLowStock } from "../utils/productStatus";
const relativeColor = {
expired: "var(--joy-palette-danger-solidBg)",
"expiring-soon": "var(--joy-palette-warning-solidBg)",
ok: "var(--joy-palette-text-tertiary)",
} as const;
// Small "in N days" / "Expired N days ago" line shown under a row's expiry date.
const ExpiryRelative = ({ expiryDate }: { expiryDate: string | null }) => {
const { t } = useTranslation();
const expiry = getExpiryDays(expiryDate);
if (!expiry) {
return null;
}
const text =
expiry.status === "expired"
? t("days-since-expired", { count: Math.abs(expiry.days) })
: t("days-until-expiry", { count: expiry.days });
return (
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: relativeColor[expiry.status] }}>
{text}
</Typography>
);
};
type FilterKey = "all" | "expired" | "soon" | "low";
const matchesFilter = (row: ProductRow, filter: FilterKey) => {
if (filter === "all") return true;
if (filter === "expired") return isExpired(row.expiryDateRaw);
if (filter === "soon") return isExpiringSoon(row.expiryDateRaw);
return isLowStock(Number(row.stockLabel));
};
export const InventoryPage = () => { export const InventoryPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,6 +59,9 @@ export const InventoryPage = () => {
header: "", header: "",
text: "", text: "",
}); });
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<FilterKey>("all");
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const showError = (error: unknown) => { const showError = (error: unknown) => {
const errorCode = (error as { code?: string })?.code; const errorCode = (error as { code?: string })?.code;
@@ -58,8 +89,9 @@ export const InventoryPage = () => {
} }
}, [productsError, productsErrorObj]); }, [productsError, productsErrorObj]);
const rows: ProductRow[] = (productsData ?? []).map( const rows: ProductRow[] = useMemo(
(product: any, index: number) => ({ () =>
(productsData ?? []).map((product: any, index: number) => ({
id: String(product?.uuid ?? index), id: String(product?.uuid ?? index),
uuid: String(product?.uuid ?? ""), uuid: String(product?.uuid ?? ""),
name: product?.name ?? t("product-name"), name: product?.name ?? t("product-name"),
@@ -72,223 +104,297 @@ export const InventoryPage = () => {
location: product?.storage_location_name ?? "-", location: product?.storage_location_name ?? "-",
locationDetail: "", locationDetail: "",
expiryDate: formatDate(product?.expiry_date), expiryDate: formatDate(product?.expiry_date),
expiryDateRaw: product?.expiry_date ?? null,
refillDate: formatDate(product?.bottling_date), refillDate: formatDate(product?.bottling_date),
}), })),
[productsData, t],
); );
const stats = useMemo(
() => ({
expired: rows.filter((row) => isExpired(row.expiryDateRaw)).length,
expiringSoon: rows.filter((row) => isExpiringSoon(row.expiryDateRaw)).length,
lowStock: rows.filter((row) => isLowStock(Number(row.stockLabel))).length,
unitsStored: rows.reduce((sum, row) => sum + Number(row.stockLabel), 0),
}),
[rows],
);
const filteredRows = useMemo(() => {
const query = search.trim().toLowerCase();
return rows
.filter((row) => matchesFilter(row, filter))
.filter(
(row) =>
!query ||
row.name.toLowerCase().includes(query) ||
row.description.toLowerCase().includes(query),
);
}, [rows, search, filter]);
const groups = useMemo(() => {
const map = new Map<string, ProductRow[]>();
for (const row of filteredRows) {
const bucket = map.get(row.location) ?? [];
bucket.push(row);
map.set(row.location, bucket);
}
return Array.from(map.entries()).map(([name, groupRows]) => ({
name,
rows: groupRows,
units: groupRows.reduce((sum, row) => sum + Number(row.stockLabel), 0),
}));
}, [filteredRows]);
const storageCount = useMemo(() => new Set(rows.map((row) => row.location)).size, [rows]);
const toggleGroup = (name: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const [selected, setSelected] = useState<readonly string[]>([]); const [selected, setSelected] = useState<readonly string[]>([]);
const { mutate: deleteSelection, isPending: isDeleting } = useMutation({ const { mutate: deleteSelection, isPending: isDeleting } = useMutation({
mutationFn: (values: string[]) => deleteSelectedProducts(values), mutationFn: (values: string[]) => deleteSelectedProducts(values),
onSuccess: () => { onSuccess: () => {
setSelected([]); setSelected([]);
queryClient.invalidateQueries({ queryKey: ["products"] }); void queryClient.invalidateQueries({ queryKey: ["products"] });
}, },
onError: showError, onError: showError,
}); });
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => { const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) { setSelected(event.target.checked ? filteredRows.map((row) => row.id) : []);
setSelected(rows.map((row) => row.id));
return;
}
setSelected([]);
}; };
const handleClick = (_event: React.MouseEvent<unknown>, id: string) => { const handleClick = (_event: MouseEvent<unknown>, id: string) => {
const selectedIndex = selected.indexOf(id); setSelected((prev) =>
let newSelected: readonly string[] = []; prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id],
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, id);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(
selected.slice(0, selectedIndex),
selected.slice(selectedIndex + 1),
); );
}
setSelected(newSelected);
}; };
const filterTabs: { key: FilterKey; label: string; count: number; color: "neutral" | "danger" | "warning" }[] = [
{ key: "all", label: t("filter-all"), count: rows.length, color: "neutral" },
{ key: "expired", label: t("filter-expired"), count: stats.expired, color: "danger" },
{ key: "soon", label: t("filter-soon"), count: stats.expiringSoon, color: "warning" },
{ key: "low", label: t("filter-low"), count: stats.lowStock, color: "neutral" },
];
return ( return (
<> <div className="space-y-6">
<Typography level="h2">{t("inventory")}</Typography> <PageHeader
<Typography level="body-lg">{t("inventory-subtitle")}</Typography> title={t("inventory")}
<div className="mt-4 flex items-center gap-3"> subtitle={t("inventory-summary", { count: rows.length, storages: storageCount })}
actions={
<Button <Button
startDecorator={<AddIcon />} startDecorator={<Plus size={16} />}
onClick={() => navigate({ to: "/app/add-product" })} onClick={() => navigate({ to: "/app/add-product" })}
variant="solid" variant="solid"
className="btn-lift rounded-2xl"
> >
{t("add")} {t("add")}
</Button> </Button>
}
/>
{productsIsLoading && <CircularProgress size="sm" />} {productsIsLoading && <CircularProgress size="sm" />}
</div> {alert.isAlert && <MyAlert type={alert.type} header={alert.header} text={alert.text} />}
{alert.isAlert && (
<Alert <StatTiles
variant="soft" expired={stats.expired}
color={alert.type} expiringSoon={stats.expiringSoon}
className="mt-4 rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]" lowStock={stats.lowStock}
unitsStored={stats.unitsStored}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("search-products")}
startDecorator={<Search size={16} />}
size="lg"
variant="outlined"
className="rounded-2xl sm:max-w-xs"
/>
<div className="flex flex-wrap gap-2">
{filterTabs.map((tab) => (
<Chip
key={tab.key}
variant={filter === tab.key ? "solid" : "soft"}
color={filter === tab.key ? "neutral" : tab.color}
onClick={() => setFilter(tab.key)}
className="cursor-pointer rounded-full px-3 transition-all duration-150 active:scale-95"
> >
{alert.header} {tab.label} {tab.count}
<br /> </Chip>
{alert.text} ))}
</Alert> </div>
)} </div>
<Sheet <Sheet
variant="outlined" variant="outlined"
className="mt-6 flex min-h-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white/80 shadow-sm sm:h-[calc(100vh-260px)]" className="flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl"
sx={{ border: "1px solid", borderColor: "divider", bgcolor: "background.surface" }}
> >
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4 text-slate-700"> {selected.length > 0 && (
<Typography level="body-lg" fontWeight="bold"> <div
{t("inventory")} className="flex items-center justify-between px-6 py-3"
style={{ borderBottom: "1px solid var(--joy-palette-divider)" }}
>
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-secondary)" }}>
{selected.length} {t("selected")}
</Typography> </Typography>
<Button <Button
size="sm" size="sm"
color="danger" color="danger"
variant="solid" variant="solid"
disabled={selected.length === 0 || isDeleting} startDecorator={<Trash2 size={14} />}
disabled={isDeleting}
onClick={() => deleteSelection([...selected])} onClick={() => deleteSelection([...selected])}
className="rounded-xl"
> >
Delete {t("delete")}
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-auto"> )}
{/* Desktop: grouped table */}
<div className="hidden overflow-auto lg:block">
<Table <Table
aria-labelledby="tableTitle"
stickyHeader stickyHeader
stripe="odd" stripe="odd"
variant="plain" variant="plain"
hoverRow hoverRow
className="min-w-240 text-slate-700" className="w-full"
sx={{ sx={{
"--TableCell-headBackground": tableLayout: "fixed",
"var(--joy-palette-background-surface)", color: "var(--joy-palette-text-secondary)",
"& thead": { "--TableCell-headBackground": "var(--joy-palette-background-level1)",
position: "sticky",
top: 0,
zIndex: 3,
backgroundColor: "rgb(248 250 252)",
},
"& thead tr": {
backgroundColor: "rgb(248 250 252)",
},
"& thead th:nth-child(2)": {
width: "40%",
},
"& thead th:nth-child(3)": {
width: "14%",
},
"& thead th": { "& thead th": {
zIndex: 2, backgroundColor: "var(--joy-palette-background-level1)",
backgroundColor: "rgb(248 250 252)",
backgroundImage: "none", backgroundImage: "none",
color: "var(--joy-palette-text-secondary)",
},
"& thead th:nth-child(1)": { width: "40px" },
"& thead th:nth-child(2)": { width: "26%" },
"& thead th:nth-child(6)": { width: "8%" },
"& tr > *:nth-child(n+3)": { textAlign: "left" },
"& tbody tr": {
borderTop: "1px solid var(--joy-palette-divider)",
transition: "background-color 120ms ease",
}, },
"& tr > *:nth-child(n+4)": { textAlign: "left" },
}} }}
> >
<thead> <thead>
<tr className="text-slate-600"> <tr>
<th className="px-4 py-4"> <th className="px-2 py-4">
<Checkbox <Checkbox
checked={rows.length > 0 && selected.length === rows.length} checked={filteredRows.length > 0 && selected.length === filteredRows.length}
indeterminate={ indeterminate={selected.length > 0 && selected.length < filteredRows.length}
selected.length > 0 && selected.length < rows.length
}
onChange={handleSelectAllClick} onChange={handleSelectAllClick}
slotProps={{ input: { "aria-label": "select all" } }} slotProps={{ input: { "aria-label": "select all" } }}
sx={{ verticalAlign: "sub" }}
/> />
</th> </th>
<th className="px-6 py-4">{t("product-name")}</th> <th className="px-3 py-4">{t("product-name")}</th>
<th className="px-6 py-4">{t("price")}</th> <th className="px-3 py-4">{t("stock")}</th>
<th className="px-6 py-4">{t("stock")}</th> <th className="px-3 py-4">{t("price")}</th>
<th className="px-6 py-4">{t("storage-place")}</th> <th className="px-3 py-4">{t("expiry-date")}</th>
<th className="px-6 py-4">{t("expiry-date")}</th> <th className="px-3 py-4 text-right">{t("actions")}</th>
<th className="px-6 py-4">{t("bottling-date")}</th>
<th className="px-6 py-4 text-right">{t("actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, index) => { {groups.map((group) => {
const isCollapsed = collapsedGroups.has(group.name);
return (
<Fragment key={`group-${group.name}`}>
<tr>
<td colSpan={6} className="px-3 py-3" style={{ backgroundColor: "var(--joy-palette-background-level1)" }}>
<button
type="button"
onClick={() => toggleGroup(group.name)}
className="flex items-center gap-2 font-semibold"
style={{ color: "var(--joy-palette-text-primary)" }}
>
<ChevronDown
size={16}
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : ""}`}
/>
{group.name}
<span className="font-normal" style={{ color: "var(--joy-palette-text-tertiary)" }}>
{group.rows.length} {t("products")} &middot; {group.units} {t("units")}
</span>
</button>
</td>
</tr>
{!isCollapsed &&
group.rows.map((row) => {
const isItemSelected = selected.includes(row.id); const isItemSelected = selected.includes(row.id);
const labelId = `inventory-checkbox-${index}`; const status = getProductStatus(row.expiryDateRaw);
return ( return (
<tr <tr
key={row.id} key={row.id}
className="border-t border-slate-200"
onClick={(event) => handleClick(event, row.id)} onClick={(event) => handleClick(event, row.id)}
role="checkbox" role="checkbox"
aria-checked={isItemSelected} aria-checked={isItemSelected}
tabIndex={-1}
> >
<th scope="row" className="px-4 py-5"> <td className="px-2 py-4">
<Checkbox <Checkbox checked={isItemSelected} sx={{ verticalAlign: "top" }} />
checked={isItemSelected} </td>
slotProps={{ input: { "aria-labelledby": labelId } }} <td className="px-3 py-4">
sx={{ verticalAlign: "top" }} <div className="flex min-w-0 items-stretch gap-3">
/> <ProductStatusBar status={status} />
</th>
<th id={labelId} scope="row" className="px-6 py-5">
<div className="flex items-center gap-4">
<Avatar size="lg" variant="soft" src={row.imageUrl} />
<div className="min-w-0"> <div className="min-w-0">
<Typography <Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
level="title-md"
className="text-slate-900"
>
{row.name} {row.name}
</Typography> </Typography>
<Typography <Typography level="body-sm" className="truncate normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
level="body-sm"
className="text-slate-500"
>
{row.description} {row.description}
</Typography> </Typography>
</div> </div>
</div> </div>
</th>
<td className="px-6 py-5">
<Typography level="title-md">{row.price}</Typography>
<Typography level="body-sm" className="text-slate-400">
{Cookies.get("currency")}
</Typography>
</td> </td>
<td className="px-6 py-5"> <td className="px-3 py-4">
<Chip <Typography
variant="soft" level="title-md"
color="neutral" sx={{
size="lg" color: isLowStock(Number(row.stockLabel))
className="px-3" ? "var(--joy-palette-danger-solidBg)"
: "var(--joy-palette-text-primary)",
}}
> >
{row.stock} {row.stock}
</Chip>
</td>
<td className="px-6 py-5">
<Typography level="title-md">{row.location}</Typography>
<Typography level="body-sm" className="text-slate-500">
{row.locationDetail}
</Typography> </Typography>
</td> </td>
<td className="px-6 py-5"> <td className="px-3 py-4">
<Typography level="title-md">{row.expiryDate}</Typography> <Mono>
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{row.price === "-" ? "-" : `${row.price} ${Cookies.get("currency") ?? ""}`}
</Typography>
</Mono>
</td> </td>
<td className="px-6 py-5"> <td className="px-3 py-4">
<Typography level="title-md">{row.refillDate}</Typography> <Mono>
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
{row.expiryDate}
</Typography>
</Mono>
<ExpiryRelative expiryDate={row.expiryDateRaw} />
</td> </td>
<td className="px-6 py-5 text-right"> <td className="px-3 py-4 text-right">
<Button <Button
onClick={() => onClick={() => navigate({ to: `/app/view-product?product=${row.uuid}` })}
navigate({
to: `/app/view-product?product=${row.uuid}`,
})
}
variant="outlined" variant="outlined"
size="sm" size="sm"
className="rounded-xl"
> >
{t("details")} {t("details")}
</Button> </Button>
@@ -296,10 +402,96 @@ export const InventoryPage = () => {
</tr> </tr>
); );
})} })}
</Fragment>
);
})}
</tbody> </tbody>
</Table> </Table>
</div> </div>
{/* Mobile: grouped card list */}
<div className="lg:hidden">
{groups.map((group) => {
const isCollapsed = collapsedGroups.has(group.name);
return (
<div key={group.name}>
<button
type="button"
onClick={() => toggleGroup(group.name)}
className="flex w-full items-center gap-2 px-4 py-3 text-left font-semibold"
style={{
backgroundColor: "var(--joy-palette-background-level1)",
color: "var(--joy-palette-text-primary)",
borderTop: "1px solid var(--joy-palette-divider)",
}}
>
<ChevronDown
size={16}
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? "-rotate-90" : ""}`}
/>
{group.name}
<span className="ml-auto font-normal" style={{ color: "var(--joy-palette-text-tertiary)" }}>
{group.rows.length}
</span>
</button>
<div
className="grid transition-[grid-template-rows] duration-200 ease-out"
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
{group.rows.map((row) => {
const status = getProductStatus(row.expiryDateRaw);
return (
<button
key={row.id}
type="button"
onClick={() => navigate({ to: `/app/view-product?product=${row.uuid}` })}
className="flex w-full items-stretch gap-3 px-4 py-3 text-left"
style={{ borderTop: "1px solid var(--joy-palette-divider)" }}
>
<ProductStatusBar status={status} />
<div className="min-w-0 flex-1">
<Typography level="title-md" className="truncate" sx={{ color: "var(--joy-palette-text-primary)" }}>
{row.name}
</Typography>
<Mono>
<Typography level="body-sm" className="normal-case" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{row.expiryDate}
</Typography>
</Mono>
</div>
<Typography
level="title-md"
className="shrink-0"
sx={{
color: isLowStock(Number(row.stockLabel))
? "var(--joy-palette-danger-solidBg)"
: "var(--joy-palette-text-primary)",
}}
>
{row.stockLabel} <span className="text-sm font-normal">{t("pcs")}</span>
</Typography>
</button>
);
})}
</div>
</div>
</div>
);
})}
<div className="p-4">
<Button
startDecorator={<Plus size={16} />}
onClick={() => navigate({ to: "/app/add-product" })}
variant="solid"
size="lg"
className="w-full rounded-2xl"
>
{t("add-product")}
</Button>
</div>
</div>
</Sheet> </Sheet>
</> </div>
); );
}; };
+77 -212
View File
@@ -1,37 +1,27 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getStorages } from "../utils/api/storages.ts"; import { getStorages } from "../utils/api/storages.ts";
import { import { Button, Chip, CircularProgress, Option, Select, Typography } from "@mui/joy";
CircularProgress,
Typography,
Select,
Option,
Input,
Button,
Chip,
Divider,
Box,
Alert,
} from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { mutateProduct, getProductDetails } from "../utils/api/products.ts"; import { Link } from "@tanstack/react-router";
import { getProductDetails, mutateProduct } from "../utils/api/products.ts";
import { toInputDate } from "../utils/uxFncs"; import { toInputDate } from "../utils/uxFncs";
import type { import type {
ProductFormValues,
productDetailsInterface,
AlertInterface, AlertInterface,
productDetailsInterface,
ProductFormValues,
Storage, Storage,
} from "../misc/interfaces"; } from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError"; import type { ApiError } from "../utils/api/apiError";
import Cookies from "js-cookie"; import { AmountStepper } from "../components/AmountStepper.tsx";
import ElectricBoltIcon from "@mui/icons-material/ElectricBolt"; import { ExpiryBadge } from "../components/ProductStatusBar.tsx";
import { MyAlert } from "../components/MyAlert.tsx";
export const ProductQuickView = () => { export const ProductQuickView = () => {
const uuid = window.location.search.split("uuid=")[1]; const uuid = window.location.search.split("uuid=")[1];
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [success, setSuccess] = useState(false);
const [alert, setAlert] = useState<AlertInterface>({ const [alert, setAlert] = useState<AlertInterface>({
isAlert: false, isAlert: false,
type: "neutral", type: "neutral",
@@ -81,6 +71,8 @@ export const ProductQuickView = () => {
} }
}, [storagesError, storagesErrorObj]); }, [storagesError, storagesErrorObj]);
// Only Amount/Expiry/Storage are shown here — name/description/bottling
// date/price stay in form state untouched and are sent back as-is on save.
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
amount: 0, amount: 0,
@@ -101,15 +93,10 @@ export const ProductQuickView = () => {
}); });
const { mutate, isPending } = useMutation({ const { mutate, isPending } = useMutation({
mutationFn: ({ mutationFn: ({ values, uuid }: { values: ProductFormValues; uuid: string }) =>
values, mutateProduct(values, uuid),
uuid,
}: {
values: ProductFormValues;
uuid: string;
}) => mutateProduct(values, uuid),
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
setSuccess(true); setAlert({ isAlert: true, type: "success", header: t("success"), text: t("save") });
queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] }); queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] });
}, },
onError: showError, onError: showError,
@@ -121,195 +108,96 @@ export const ProductQuickView = () => {
} }
form.setFieldValue("amount", productDetails.amount ?? 0); form.setFieldValue("amount", productDetails.amount ?? 0);
form.setFieldValue( form.setFieldValue("bottling_date", toInputDate(productDetails.bottling_date));
"bottling_date",
toInputDate(productDetails.bottling_date),
);
form.setFieldValue("description", productDetails.description ?? ""); form.setFieldValue("description", productDetails.description ?? "");
form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date)); form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date));
form.setFieldValue("name", productDetails.name ?? ""); form.setFieldValue("name", productDetails.name ?? "");
form.setFieldValue("price", productDetails.price ?? ""); form.setFieldValue("price", productDetails.price ?? "");
form.setFieldValue( form.setFieldValue("storage_location_uuid", productDetails.storage_location_uuid ?? "");
"storage_location_uuid",
productDetails.storage_location_uuid ?? "",
);
}, [form, productDetails]); }, [form, productDetails]);
return ( return (
<> <div className="flex min-h-screen items-start justify-center px-4 py-10 sm:items-center" style={{ backgroundColor: "var(--joy-palette-background-body)" }}>
<div className="space-y-6"> <div className="w-full max-w-md space-y-5">
<div className="flex flex-wrap items-center gap-3"> <Chip variant="soft" color="warning" size="sm" className="rounded-full px-3 font-semibold">
<div className="space-y-1"> {t("scanned-badge")}
<Typography level="h2" fontWeight="1000" className="text-slate-900">
<ElectricBoltIcon color="primary" />
{t("product-details-quick")}
<ElectricBoltIcon color="primary" />
</Typography>
</div>
<Chip
variant="soft"
color="primary"
className="ml-auto rounded-full px-3"
>
{t("details")}
</Chip> </Chip>
</div>
{productDetailsLoading && <CircularProgress size="sm" />} {productDetailsLoading && <CircularProgress size="sm" />}
</div>
{isSuccess && ( {isSuccess && (
<Box className="mt-6 rounded-3xl border border-white/70 bg-white/80 p-6 shadow-[0_24px_60px_rgba(12,38,78,0.12)] backdrop-blur"> <>
<div className="space-y-1">
<Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
{productDetails.name}
</Typography>
<Typography level="body-lg" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("opened-from-jar-label")}
</Typography>
<ExpiryBadge expiryDate={productDetails.expiry_date} />
</div>
<form <form
className="space-y-6" className="space-y-5 rounded-3xl p-6"
style={{ border: "1px solid var(--joy-palette-divider)", backgroundColor: "var(--joy-palette-background-surface)" }}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
form.handleSubmit(); form.handleSubmit();
}} }}
> >
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="space-y-1 text-center">
<div className="space-y-4"> <Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
<div className="space-y-1"> {t("amount-in-storage")}
<Typography level="title-md" className="text-slate-900">
{t("product-name")}
</Typography> </Typography>
<form.Field name="name"> <form.Field name="amount">
{(field) => ( {(field) => (
<Input <AmountStepper
type="text"
value={field.state.value} value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)} onChange={field.handleChange}
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]"
/> />
)} )}
</form.Field> </form.Field>
</div> <Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
<div className="space-y-1"> {t("set-to-zero-hint")}
<Typography level="title-md" className="text-slate-900">
{t("description")}
</Typography> </Typography>
<form.Field name="description">
{(field) => (
<Input
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]"
/>
)}
</form.Field>
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1"> <div
<Typography level="title-md" className="text-slate-900"> className="flex items-center justify-between rounded-2xl px-4 py-3"
style={{ border: "1px solid var(--joy-palette-divider)" }}
>
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("expiry-date")} {t("expiry-date")}
</Typography> </Typography>
<form.Field name="expiry_date"> <form.Field name="expiry_date">
{(field) => ( {(field) => (
<Input <input
type="date" type="date"
value={field.state.value} value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)} onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" className="border-none bg-transparent text-right font-semibold outline-none"
variant="outlined" style={{ color: "var(--joy-palette-danger-solidBg)" }}
className="rounded-2xl bg-white/90"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <div
{t("bottling-date")} className="flex items-center justify-between rounded-2xl px-4 py-3"
</Typography> style={{ border: "1px solid var(--joy-palette-divider)" }}
<form.Field name="bottling_date"> >
{(field) => ( <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
<Input
type="date"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90"
/>
)}
</form.Field>
</div>
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/70 bg-linear-to-br from-[#f7fbff] via-[#f2f6fb] to-[#eef3f9] p-5 shadow-[0_16px_40px_rgba(12,38,78,0.08)]">
<Typography level="title-lg" className="text-slate-900">
{t("inventory")}
</Typography>
<Divider className="my-3" />
<div className="grid gap-4">
<div className="space-y-1">
<Typography level="title-md" className="text-slate-900">
{t("amount")}
</Typography>
<form.Field name="amount">
{(field) => (
<Input
type="number"
color="neutral"
id="amountInput"
placeholder={t("amount")}
value={field.state.value}
variant="soft"
size="lg"
onChange={(e) => {
const nextValue = Number(e.target.value);
field.handleChange(
Number.isNaN(nextValue) ? 0 : nextValue,
);
}}
onBlur={field.handleBlur}
className="rounded-2xl bg-white/80"
/>
)}
</form.Field>
</div>
<div className="space-y-1">
<Typography level="title-md" className="text-slate-900">
{t("price")}
</Typography>
<form.Field name="price">
{(field) => (
<Input
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90"
/>
)}
</form.Field>
<Typography level="body-sm" className="text-slate-500">
{Cookies.get("currency")}
</Typography>
</div>
<div className="space-y-1">
<Typography level="title-md" className="text-slate-900">
{t("storage-place")} {t("storage-place")}
</Typography> </Typography>
<form.Field name="storage_location_uuid"> <form.Field name="storage_location_uuid">
{(field) => ( {(field) => (
<Select <Select
value={field.state.value} value={field.state.value}
onChange={(_event, value) => onChange={(_event, value) => field.handleChange(value ?? "")}
field.handleChange(value ?? "") variant="plain"
} size="sm"
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90"
> >
{storages?.map((storage) => ( {storages?.map((storage) => (
<Option key={storage.uuid} value={storage.uuid}> <Option key={storage.uuid} value={storage.uuid}>
@@ -320,50 +208,27 @@ export const ProductQuickView = () => {
)} )}
</form.Field> </form.Field>
</div> </div>
</div>
</div> <Link
</div> to="/app/view-product"
</div> search={{ product: uuid }}
<div className="flex flex-wrap items-center justify-between gap-3"> className="block text-center text-sm font-semibold hover:underline"
<Typography level="body-sm" className="text-slate-500"> style={{ color: "var(--joy-palette-primary-solidBg)" }}
{t("product-details")}
</Typography>
<Button
type="submit"
loading={isPending}
size="lg"
className="rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]"
> >
{t("open-full-details")}
</Link>
{alert.isAlert && (
<MyAlert type={alert.type} header={alert.header} text={alert.text} />
)}
<Button type="submit" loading={isPending} size="lg" color="primary" variant="solid" className="btn-lift w-full rounded-2xl">
{t("save")} {t("save")}
</Button> </Button>
</div>
{alert.isAlert && (
<Alert
color={alert.type}
variant="soft"
className="rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]"
>
{alert.header}
<br />
{alert.text}
</Alert>
)}
{success && (
<Alert
color="success"
variant="soft"
className="rounded-2xl border border-emerald-200/70 bg-emerald-50/80 text-emerald-700 shadow-[0_14px_30px_rgba(16,185,129,0.18)]"
>
<div className="flex w-full items-center justify-between">
<Typography level="body-sm" className="text-emerald-700">
{t("success")}
</Typography>
</div>
</Alert>
)}
</form> </form>
</Box>
)}
</> </>
)}
</div>
</div>
); );
}; };
+153 -79
View File
@@ -1,27 +1,31 @@
import { import { Button, CircularProgress, Divider, Input, Sheet, Typography } from "@mui/joy";
Input,
Button,
CircularProgress,
Typography,
Sheet,
Chip,
Divider,
Alert,
} from "@mui/joy";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SettingsIntf } from "../misc/interfaces"; import type { AlertInterface, SettingsIntf } from "../misc/interfaces";
import type { AlertInterface } from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError"; import type { ApiError } from "../utils/api/apiError";
import { mutateSettings, fetchSettings } from "../utils/api/settings"; import { fetchSettings, mutateSettings } from "../utils/api/settings";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useColorScheme } from "@mui/joy/styles";
import { ChevronRight } from "lucide-react";
import { ChangePasswordModal } from "../components/modals/ChangePasswordModal"; import { ChangePasswordModal } from "../components/modals/ChangePasswordModal";
import { MyAlert } from "../components/MyAlert.tsx";
import { PageHeader } from "../components/PageHeader.tsx";
import { changeTranslation } from "../utils/uxFncs";
import { useLogout } from "../hooks/useLogout.ts";
const cardSx = {
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
} as const;
export const Settings = () => { export const Settings = () => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mode, setMode } = useColorScheme();
const { logout } = useLogout();
const [modal, setModal] = useState(false); const [modal, setModal] = useState(false);
const [alert, setAlert] = useState<AlertInterface>({ const [alert, setAlert] = useState<AlertInterface>({
isAlert: false, isAlert: false,
@@ -83,67 +87,58 @@ export const Settings = () => {
const { mutate } = useMutation({ const { mutate } = useMutation({
mutationFn: (values: SettingsIntf) => mutateSettings(values), mutationFn: (values: SettingsIntf) => mutateSettings(values),
onSuccess() { onSuccess() {
setAlert({
isAlert: true,
type: "success",
header: t("success"),
text: t("SE001"),
});
queryClient.invalidateQueries({ queryKey: ["settings"] }); queryClient.invalidateQueries({ queryKey: ["settings"] });
}, },
onError: showError, onError: showError,
}); });
const segmentBtn = (active: boolean) => ({
className: "rounded-xl px-4",
variant: active ? ("solid" as const) : ("plain" as const),
color: active ? ("primary" as const) : ("neutral" as const),
sx: { color: active ? undefined : "var(--joy-palette-text-secondary)" },
});
return ( return (
<> <>
<ChangePasswordModal isOpen={modal} setOpen={setModal} /> <ChangePasswordModal alert={setAlert} isOpen={modal} setOpen={setModal} />
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center gap-3"> <PageHeader title={t("settings")} subtitle={t("settings-sub")} />
<div className="space-y-1">
<Typography level="h2" className="text-slate-900">
{t("settings")}
</Typography>
<Typography level="body-lg" className="text-slate-500">
{t("settings-sub")}
</Typography>
</div>
<Chip
variant="soft"
color="primary"
className="ml-auto rounded-full px-3"
>
{t("preferences")}
</Chip>
</div>
</div>
<Sheet className="mt-6 rounded-3xl border border-white/70 bg-white/80 p-6 shadow-[0_24px_60px_rgba(12,38,78,0.12)] backdrop-blur">
{alert.isAlert && ( {alert.isAlert && (
<Alert <MyAlert type={alert.type} header={alert.header} text={alert.text} />
variant="soft"
color={alert.type}
className="mb-6 rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]"
>
{alert.header}
<br />
{alert.text}
</Alert>
)} )}
{settingsPending ? ( {settingsPending ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<CircularProgress size="lg" /> <CircularProgress size="lg" />
</div> </div>
) : ( ) : (
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
<div className="space-y-6">
<Sheet className="rounded-3xl p-6" sx={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("general")}
</Typography>
<Divider className="my-4" />
<form <form
className="space-y-6" className="space-y-5"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
form.handleSubmit(); form.handleSubmit();
}} }}
> >
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]"> <div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-5">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("app-name")} {t("app-name")}
</Typography> </Typography>
<Typography level="body-sm" className="text-slate-500">
{t("app-name-sub")}
</Typography>
<form.Field name="app-name"> <form.Field name="app-name">
{(field) => ( {(field) => (
<Input <Input
@@ -152,18 +147,18 @@ export const Settings = () => {
size="lg" size="lg"
variant="outlined" variant="outlined"
placeholder="Stockhome" placeholder="Stockhome"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("app-name-sub")}
</Typography>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("currency")} {t("currency")}
</Typography> </Typography>
<Typography level="body-sm" className="text-slate-500">
{t("currency-sub")}
</Typography>
<form.Field name="currency"> <form.Field name="currency">
{(field) => ( {(field) => (
<Input <Input
@@ -172,44 +167,123 @@ export const Settings = () => {
size="lg" size="lg"
variant="outlined" variant="outlined"
placeholder="EUR" placeholder="EUR"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("currency-sub")}
</Typography>
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/70 bg-linear-to-br from-[#f7fbff] via-[#f2f6fb] to-[#eef3f9] p-5 shadow-[0_16px_40px_rgba(12,38,78,0.08)]"> <div className="flex justify-end">
<Typography level="title-lg" className="text-slate-900"> <Button type="submit" size="lg" color="primary" className="btn-lift rounded-2xl">
{t("save")}
</Button>
</div>
</form>
</Sheet>
<Sheet className="rounded-3xl p-6" sx={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("appearance-language")}
</Typography>
<Divider className="my-4" />
<div className="space-y-5">
<div className="space-y-2">
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("theme")}
</Typography>
<div className="inline-flex gap-1 rounded-2xl border border-solid p-1" style={{ borderColor: "var(--joy-palette-divider)" }}>
<Button {...segmentBtn(mode === "light")} onClick={() => setMode("light")}>
{t("theme-light")}
</Button>
<Button {...segmentBtn(mode === "dark")} onClick={() => setMode("dark")}>
{t("theme-dark")}
</Button>
<Button {...segmentBtn(mode === "system")} onClick={() => setMode("system")}>
{t("theme-system")}
</Button>
</div>
</div>
<div className="space-y-2">
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("language")}
</Typography>
<div className="inline-flex gap-1 rounded-2xl border border-solid p-1" style={{ borderColor: "var(--joy-palette-divider)" }}>
<Button
{...segmentBtn(i18n.language === "de")}
onClick={() => i18n.language !== "de" && changeTranslation()}
>
Deutsch
</Button>
<Button
{...segmentBtn(i18n.language === "en")}
onClick={() => i18n.language !== "en" && changeTranslation()}
>
English
</Button>
</div>
</div>
</div>
</Sheet>
<Sheet className="rounded-3xl p-6" sx={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("account")}
</Typography>
<Divider className="my-4" />
<div className="space-y-4">
<button
type="button"
onClick={() => setModal(true)}
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-left"
>
<div>
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("change-password")}
</Typography>
</div>
<ChevronRight size={18} color="var(--joy-palette-text-tertiary)" />
</button>
<Divider />
<div className="flex items-center justify-between">
<div>
<Typography level="title-md" sx={{ color: "var(--joy-palette-danger-solidBg)" }}>
{t("sign-out")}
</Typography>
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("sign-out-sub")}
</Typography>
</div>
<Button color="danger" variant="outlined" className="rounded-2xl" onClick={logout}>
{t("logout")}
</Button>
</div>
</div>
</Sheet>
</div>
<div
className="rounded-3xl p-6"
style={{
border: "1px solid var(--joy-palette-divider)",
background:
"linear-gradient(to bottom right, var(--joy-palette-primary-50), var(--joy-palette-background-level1), var(--joy-palette-background-level2))",
}}
>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("quick-tips")} {t("quick-tips")}
</Typography> </Typography>
<Divider className="my-3" /> <Divider className="my-3" />
<div className="space-y-3 text-md text-slate-600"> <div className="space-y-3 text-md" style={{ color: "var(--joy-palette-text-secondary)" }}>
<p>{t("quick-tips-1")}</p> <p>{t("quick-tips-1")}</p>
<p>{t("quick-tips-2")}</p> <p>{t("quick-tips-2")}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3">
<Button
type="submit"
size="lg"
color="primary"
className="rounded-2xl text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]"
>
{t("save")}
</Button>
<Button
onClick={() => setModal(true)}
size="lg"
color="warning"
className="rounded-2xl text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]"
>
{t("change-password")}
</Button>
</div>
</form>
)} )}
</Sheet> </div>
</> </>
); );
}; };
+65 -68
View File
@@ -1,21 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getStorages } from "../utils/api/storages"; import { getStorages } from "../utils/api/storages";
import { import { getProducts } from "../utils/api/products";
Sheet, import { Button, CircularProgress, Sheet, Table, Typography } from "@mui/joy";
Table,
Button,
CircularProgress,
Typography,
Alert,
} from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Storage } from "../misc/interfaces"; import type { AlertInterface, Storage } from "../misc/interfaces";
import type { AlertInterface } from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError"; import type { ApiError } from "../utils/api/apiError";
import { StorageRow } from "../components/StorageRow"; import { StorageRow } from "../components/StorageRow";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AddStorageModal } from "../components/modals/AddStorageModal"; import { AddStorageModal } from "../components/modals/AddStorageModal";
import AddIcon from "@mui/icons-material/Add"; import { AlertTriangle, Plus } from "lucide-react";
import { MyAlert } from "../components/MyAlert.tsx";
import { PageHeader } from "../components/PageHeader.tsx";
export const Storages = () => { export const Storages = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -47,6 +42,13 @@ export const Storages = () => {
queryFn: () => getStorages(), queryFn: () => getStorages(),
}); });
// Already cached by the sidebar/inventory query — just reused here for the count.
const { data: products } = useQuery<{ amount?: number }[]>({
queryKey: ["products"],
queryFn: getProducts,
});
const itemsStored = (products ?? []).reduce((sum, product) => sum + (product?.amount ?? 0), 0);
useEffect(() => { useEffect(() => {
if (storagesError && storagesErrorObj) { if (storagesError && storagesErrorObj) {
showError(storagesErrorObj); showError(storagesErrorObj);
@@ -56,107 +58,102 @@ export const Storages = () => {
return ( return (
<> <>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="min-w-65 space-y-2"> <PageHeader
<Typography level="h2" className="text-slate-900"> title={t("storages")}
{t("storages")} subtitle={t("storages-sub", { count: storages?.length ?? 0, items: itemsStored })}
</Typography> actions={
<Typography level="body-lg" className="text-slate-500"> <Button startDecorator={<Plus size={16} />} onClick={() => setModal(true)} variant="solid" className="btn-lift rounded-2xl">
{t("new-storage")}
</Button>
}
/>
<AddStorageModal isOpen={modal} setOpen={setModal} />
<div
className="flex items-start gap-3 rounded-2xl border border-solid px-4 py-3"
style={{
borderColor: "var(--joy-palette-warning-softBg)",
backgroundColor: "var(--joy-palette-warning-softBg)",
color: "var(--joy-palette-warning-softColor)",
}}
>
<AlertTriangle size={18} className="mt-0.5 shrink-0" />
<Typography level="body-lg" sx={{ color: "inherit" }}>
{t("storage-delete-info")} {t("storage-delete-info")}
</Typography> </Typography>
</div> </div>
<div className="flex items-center gap-3">
<Button
startDecorator={<AddIcon />}
onClick={() => setModal(true)}
variant="solid"
className="rounded-full px-5 py-2 text-base font-semibold shadow-sm"
>
{t("add")}
</Button>
</div>
{alert.isAlert && ( {alert.isAlert && (
<Alert <MyAlert type={alert.type} header={alert.header} text={alert.text} />
variant="soft"
color={alert.type}
className="rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]"
>
{alert.header}
<br />
{alert.text}
</Alert>
)} )}
</div> </div>
<Sheet <Sheet
variant="outlined" variant="outlined"
className="mt-6 flex min-h-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white/80 shadow-sm sm:h-[calc(100vh-260px)]" className="mt-6 flex min-h-0 w-full max-w-full flex-col overflow-hidden rounded-2xl sm:h-[calc(100vh-300px)]"
sx={{
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
}}
> >
<AddStorageModal isOpen={modal} setOpen={setModal} />
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<CircularProgress size="lg" /> <CircularProgress size="lg" />
</div> </div>
) : ( ) : (
<>
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4 text-slate-700">
<Typography level="body-lg" fontWeight="bold">
{t("storages")}
</Typography>
</div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<Table <Table
stickyHeader stickyHeader
stripe="odd" stripe="odd"
variant="plain" variant="plain"
hoverRow hoverRow
className="min-w-240 text-slate-700" className="w-full"
sx={{ sx={{
"--TableCell-headBackground": tableLayout: "fixed",
"var(--joy-palette-background-surface)", color: "var(--joy-palette-text-secondary)",
"--TableCell-headBackground": "var(--joy-palette-background-level1)",
"& thead": { "& thead": {
position: "sticky", position: "sticky",
top: 0, top: 0,
zIndex: 3, zIndex: 3,
backgroundColor: "rgb(248 250 252)", backgroundColor: "var(--joy-palette-background-level1)",
}, },
"& thead tr": { "& thead tr": {
backgroundColor: "rgb(248 250 252)", backgroundColor: "var(--joy-palette-background-level1)",
},
"& thead th:nth-child(2)": {
width: "40%",
},
"& thead th:nth-child(3)": {
width: "14%",
}, },
"& thead th": { "& thead th": {
zIndex: 2, zIndex: 2,
backgroundColor: "rgb(248 250 252)", backgroundColor: "var(--joy-palette-background-level1)",
backgroundImage: "none", backgroundImage: "none",
color: "var(--joy-palette-text-secondary)",
},
"& thead th:nth-child(1)": { width: "26%", minWidth: "140px" },
"& thead th:nth-child(2)": { width: "34%", minWidth: "160px" },
"& thead th:nth-child(3)": { width: "16%", minWidth: "110px" },
"& thead th:nth-child(4)": { width: "16%", minWidth: "110px" },
"& thead th:nth-child(5)": { width: "8%", minWidth: "90px" },
"& tr > *:nth-child(n+3)": { textAlign: "left" },
"& tbody tr": {
borderTop: "1px solid var(--joy-palette-divider)",
transition: "background-color 120ms ease",
}, },
"& tr > *:nth-child(n+4)": { textAlign: "left" },
}} }}
> >
<thead> <thead>
<tr className="text-slate-600"> <tr>
<th className="px-6 py-4">{t("storage-name")}</th> <th className="px-3 py-4">{t("storage-name")}</th>
<th className="px-6 py-4">{t("description")}</th> <th className="px-3 py-4">{t("description")}</th>
<th className="px-6 py-4">{t("created-at")}</th> <th className="px-3 py-4">{t("created-at")}</th>
<th className="px-6 py-4">{t("updated-at")}</th> <th className="px-3 py-4">{t("updated-at")}</th>
<th className="px-6 py-4 text-right"></th> <th className="px-3 py-4 text-right"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{storages?.map((storage: Storage) => ( {storages?.map((storage: Storage) => (
<StorageRow <StorageRow key={storage.uuid} storage={storage} onError={showError} />
key={storage.uuid}
storage={storage}
onError={showError}
/>
))} ))}
</tbody> </tbody>
</Table> </Table>
</div> </div>
</>
)} )}
</Sheet> </Sheet>
</> </>
+125 -146
View File
@@ -1,32 +1,35 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getStorages } from "../utils/api/storages.ts"; import { getStorages } from "../utils/api/storages.ts";
import { import { Button, CircularProgress, Input, Option, Select, Typography } from "@mui/joy";
CircularProgress,
Typography,
Select,
Option,
Input,
Button,
Chip,
Divider,
Box,
Alert,
} from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { mutateProduct, getProductDetails } from "../utils/api/products.ts"; import { useNavigate } from "@tanstack/react-router";
import {
deleteSelectedProducts,
getProductDetails,
mutateProduct,
} from "../utils/api/products.ts";
import { toInputDate } from "../utils/uxFncs"; import { toInputDate } from "../utils/uxFncs";
import type { import type {
ProductFormValues,
productDetailsInterface,
AlertInterface, AlertInterface,
productDetailsInterface,
ProductFormValues,
Storage, Storage,
} from "../misc/interfaces"; } from "../misc/interfaces";
import type { ApiError } from "../utils/api/apiError"; import type { ApiError } from "../utils/api/apiError";
import QrCodeIcon from "@mui/icons-material/QrCode"; import { QrCode, Trash2 } from "lucide-react";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { AmountStepper } from "../components/AmountStepper.tsx";
import { ExpiryBadge } from "../components/ProductStatusBar.tsx";
import { MyAlert } from "../components/MyAlert.tsx";
const cardSx = {
border: "1px solid",
borderColor: "divider",
bgcolor: "background.surface",
} as const;
interface ViewProductProps { interface ViewProductProps {
uuid: string; uuid: string;
@@ -35,8 +38,8 @@ interface ViewProductProps {
export const ViewProduct = (props: ViewProductProps) => { export const ViewProduct = (props: ViewProductProps) => {
const uuid = props.uuid; const uuid = props.uuid;
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [success, setSuccess] = useState(false);
const [alert, setAlert] = useState<AlertInterface>({ const [alert, setAlert] = useState<AlertInterface>({
isAlert: false, isAlert: false,
type: "neutral", type: "neutral",
@@ -106,48 +109,43 @@ export const ViewProduct = (props: ViewProductProps) => {
}); });
const { mutate, isPending } = useMutation({ const { mutate, isPending } = useMutation({
mutationFn: ({ mutationFn: ({ values, uuid }: { values: ProductFormValues; uuid: string }) =>
values, mutateProduct(values, uuid),
uuid,
}: {
values: ProductFormValues;
uuid: string;
}) => mutateProduct(values, uuid),
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
setSuccess(true); setAlert({ isAlert: true, type: "success", header: t("success"), text: t("save") });
queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] }); queryClient.invalidateQueries({ queryKey: ["product", variables.uuid] });
}, },
onError: showError, onError: showError,
}); });
const { mutate: deleteProduct, isPending: isDeleting } = useMutation({
mutationFn: () => deleteSelectedProducts([uuid]),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
void navigate({ to: "/app/inventory" });
},
onError: showError,
});
useEffect(() => { useEffect(() => {
if (!productDetails) { if (!productDetails) {
return; return;
} }
form.setFieldValue("amount", productDetails.amount ?? 0); form.setFieldValue("amount", productDetails.amount ?? 0);
form.setFieldValue( form.setFieldValue("bottling_date", toInputDate(productDetails.bottling_date));
"bottling_date",
toInputDate(productDetails.bottling_date),
);
form.setFieldValue("description", productDetails.description ?? ""); form.setFieldValue("description", productDetails.description ?? "");
form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date)); form.setFieldValue("expiry_date", toInputDate(productDetails.expiry_date));
form.setFieldValue("name", productDetails.name ?? ""); form.setFieldValue("name", productDetails.name ?? "");
form.setFieldValue("price", productDetails.price ?? ""); form.setFieldValue("price", productDetails.price ?? "");
form.setFieldValue( form.setFieldValue("storage_location_uuid", productDetails.storage_location_uuid ?? "");
"storage_location_uuid",
productDetails.storage_location_uuid ?? "",
);
}, [form, productDetails]); }, [form, productDetails]);
const downloadQRcode = async () => { const downloadQRcode = async () => {
const baseUrl: string = `${window.location.protocol}//${window.location.host}`; const baseUrl: string = `${window.location.protocol}//${window.location.host}`;
const url = `${baseUrl}/app/quick-view/product?uuid=${uuid}`; const url = `${baseUrl}/app/quick-view/product?uuid=${uuid}`;
const dataUrl = await QRCode.toDataURL(url, { const dataUrl = await QRCode.toDataURL(url, { width: 300, margin: 2 });
width: 300,
margin: 2,
});
const link = document.createElement("a"); const link = document.createElement("a");
link.download = "qrcode.png"; link.download = "qrcode.png";
@@ -156,26 +154,27 @@ export const ViewProduct = (props: ViewProductProps) => {
}; };
return ( return (
<>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="space-y-1"> <Typography level="h2" sx={{ color: "var(--joy-palette-text-primary)" }}>
<Typography level="h2" className="text-slate-900"> {isSuccess ? productDetails.name : t("product-details")}
{t("product-details")}
</Typography> </Typography>
</div> {isSuccess && <ExpiryBadge expiryDate={productDetails.expiry_date} />}
<Chip <Button
variant="soft" startDecorator={<Trash2 size={16} />}
color="primary" color="danger"
className="ml-auto rounded-full px-3" variant="outlined"
size="sm"
className="ml-auto rounded-2xl"
loading={isDeleting}
onClick={() => deleteProduct()}
> >
{t("details")} {t("delete")}
</Chip> </Button>
</div> </div>
{productDetailsLoading && <CircularProgress size="sm" />} {productDetailsLoading && <CircularProgress size="sm" />}
</div>
{isSuccess && ( {isSuccess && (
<Box className="mt-6 rounded-3xl border border-white/70 bg-white/80 p-6 shadow-[0_24px_60px_rgba(12,38,78,0.12)] backdrop-blur">
<form <form
className="space-y-6" className="space-y-6"
onSubmit={(e) => { onSubmit={(e) => {
@@ -183,10 +182,14 @@ export const ViewProduct = (props: ViewProductProps) => {
form.handleSubmit(); form.handleSubmit();
}} }}
> >
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="grid gap-5 lg:grid-cols-2">
<div className="space-y-4"> <div className="rounded-3xl p-6" style={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("product")}
</Typography>
<div className="mt-4 space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("product-name")} {t("product-name")}
</Typography> </Typography>
<form.Field name="name"> <form.Field name="name">
@@ -198,13 +201,13 @@ export const ViewProduct = (props: ViewProductProps) => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("description")} {t("description")}
</Typography> </Typography>
<form.Field name="description"> <form.Field name="description">
@@ -216,32 +219,14 @@ export const ViewProduct = (props: ViewProductProps) => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90 shadow-[0_10px_24px_rgba(15,23,42,0.08)]" className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("expiry-date")}
</Typography>
<form.Field name="expiry_date">
{(field) => (
<Input
type="date"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl bg-white/90"
/>
)}
</form.Field>
</div>
<div className="space-y-1">
<Typography level="title-md" className="text-slate-900">
{t("bottling-date")} {t("bottling-date")}
</Typography> </Typography>
<form.Field name="bottling_date"> <form.Field name="bottling_date">
@@ -253,48 +238,54 @@ export const ViewProduct = (props: ViewProductProps) => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90" className="rounded-2xl"
/>
)}
</form.Field>
</div>
<div className="space-y-1">
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("expiry-date")}
</Typography>
<form.Field name="expiry_date">
{(field) => (
<Input
type="date"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
size="lg"
variant="outlined"
className="rounded-2xl"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-4"> </div>
<div className="rounded-2xl border border-white/70 bg-linear-to-br from-[#f7fbff] via-[#f2f6fb] to-[#eef3f9] p-5 shadow-[0_16px_40px_rgba(12,38,78,0.08)]">
<Typography level="title-lg" className="text-slate-900"> <div className="rounded-3xl p-6" style={cardSx}>
<Typography level="title-lg" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("inventory")} {t("inventory")}
</Typography> </Typography>
<Divider className="my-3" /> <div className="mt-4 space-y-4">
<div className="grid gap-4">
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("amount")} {t("amount")}
</Typography> </Typography>
<form.Field name="amount"> <form.Field name="amount">
{(field) => ( {(field) => (
<Input <AmountStepper
type="number"
color="neutral"
id="amountInput"
placeholder={t("amount")}
value={field.state.value} value={field.state.value}
variant="soft" onChange={field.handleChange}
size="lg"
onChange={(e) => {
const nextValue = Number(e.target.value);
field.handleChange(
Number.isNaN(nextValue) ? 0 : nextValue,
);
}}
onBlur={field.handleBlur} onBlur={field.handleBlur}
className="rounded-2xl bg-white/80"
/> />
)} )}
</form.Field> </form.Field>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("price")} {t("price")}
</Typography> </Typography>
<form.Field name="price"> <form.Field name="price">
@@ -306,28 +297,28 @@ export const ViewProduct = (props: ViewProductProps) => {
onBlur={field.handleBlur} onBlur={field.handleBlur}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90" className="rounded-2xl"
endDecorator={
<Typography level="body-sm" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{Cookies.get("currency")}
</Typography>
}
/> />
)} )}
</form.Field> </form.Field>
<Typography level="body-sm" className="text-slate-500">
{Cookies.get("currency")}
</Typography>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Typography level="title-md" className="text-slate-900"> <Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("storage-place")} {t("storage-place")}
</Typography> </Typography>
<form.Field name="storage_location_uuid"> <form.Field name="storage_location_uuid">
{(field) => ( {(field) => (
<Select <Select
value={field.state.value} value={field.state.value}
onChange={(_event, value) => onChange={(_event, value) => field.handleChange(value ?? "")}
field.handleChange(value ?? "")
}
size="lg" size="lg"
variant="outlined" variant="outlined"
className="rounded-2xl bg-white/90" className="rounded-2xl"
> >
{storages?.map((storage) => ( {storages?.map((storage) => (
<Option key={storage.uuid} value={storage.uuid}> <Option key={storage.uuid} value={storage.uuid}>
@@ -339,58 +330,46 @@ export const ViewProduct = (props: ViewProductProps) => {
</form.Field> </form.Field>
</div> </div>
</div> </div>
</div>
</div> <div
</div> className="mt-4 flex items-center gap-3 rounded-2xl p-4"
<div className="flex flex-wrap items-center justify-between gap-3"> style={{ backgroundColor: "var(--joy-palette-background-level1)" }}
<Typography level="body-sm" className="text-slate-500"> >
{t("product-details")} <QrCode size={20} color="var(--joy-palette-text-tertiary)" className="shrink-0" />
<div className="min-w-0">
<Typography level="title-md" sx={{ color: "var(--joy-palette-text-primary)" }}>
{t("jar-label")}
</Typography> </Typography>
<Typography level="body-sm" className="normal-case font-normal" sx={{ color: "var(--joy-palette-text-tertiary)" }}>
{t("jar-label-hint")}
</Typography>
</div>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button <Button
startDecorator={<QrCodeIcon />} type="button"
loading={isPending} startDecorator={<QrCode size={16} />}
onClick={() => downloadQRcode()} onClick={() => downloadQRcode()}
size="lg" size="lg"
className="rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]" variant="outlined"
color="neutral"
className="rounded-2xl"
> >
{t("download-qr-code")} {t("download-qr-code")}
</Button> </Button>
<Button <Button type="submit" loading={isPending} size="lg" color="primary" variant="solid" className="btn-lift rounded-2xl">
type="submit"
loading={isPending}
size="lg"
className="rounded-2xl bg-[#0b6bcb] text-white shadow-[0_16px_36px_rgba(11,107,203,0.35)] transition hover:-translate-y-0.5 hover:bg-[#095aa7]"
>
{t("save")} {t("save")}
</Button> </Button>
</div> </div>
{alert.isAlert && ( {alert.isAlert && (
<Alert <MyAlert type={alert.type} header={alert.header} text={alert.text} />
color={alert.type}
variant="soft"
className="rounded-2xl border border-rose-200/70 bg-rose-50/80 text-rose-700 shadow-[0_12px_30px_rgba(220,38,38,0.12)]"
>
{alert.header}
<br />
{alert.text}
</Alert>
)}
{success && (
<Alert
color="success"
variant="soft"
className="rounded-2xl border border-emerald-200/70 bg-emerald-50/80 text-emerald-700 shadow-[0_14px_30px_rgba(16,185,129,0.18)]"
>
<div className="flex w-full items-center justify-between">
<Typography level="body-sm" className="text-emerald-700">
{t("success")}
</Typography>
</div>
</Alert>
)} )}
</form> </form>
</Box>
)} )}
</> </div>
); );
}; };
+125 -97
View File
@@ -12,12 +12,13 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as AppHiddenLayoutRouteImport } from './routes/app/_hiddenLayout' import { Route as AppHiddenLayoutRouteImport } from './routes/app/_hiddenLayout'
import { Route as AppHiddenLayoutViewProductRouteImport } from './routes/app/_hiddenLayout/view-product' import { Route as AppHiddenLayoutAuthedRouteImport } from './routes/app/_hiddenLayout/_authed'
import { Route as AppHiddenLayoutStoragesRouteImport } from './routes/app/_hiddenLayout/storages'
import { Route as AppHiddenLayoutInventoryRouteImport } from './routes/app/_hiddenLayout/inventory'
import { Route as AppHiddenLayoutAppSettingsRouteImport } from './routes/app/_hiddenLayout/app-settings'
import { Route as AppHiddenLayoutAddProductRouteImport } from './routes/app/_hiddenLayout/add-product'
import { Route as AppHiddenLayoutQuickViewProductRouteImport } from './routes/app/_hiddenLayout/quick-view/product' import { Route as AppHiddenLayoutQuickViewProductRouteImport } from './routes/app/_hiddenLayout/quick-view/product'
import { Route as AppHiddenLayoutAuthedViewProductRouteImport } from './routes/app/_hiddenLayout/_authed/view-product'
import { Route as AppHiddenLayoutAuthedStoragesRouteImport } from './routes/app/_hiddenLayout/_authed/storages'
import { Route as AppHiddenLayoutAuthedInventoryRouteImport } from './routes/app/_hiddenLayout/_authed/inventory'
import { Route as AppHiddenLayoutAuthedAppSettingsRouteImport } from './routes/app/_hiddenLayout/_authed/app-settings'
import { Route as AppHiddenLayoutAuthedAddProductRouteImport } from './routes/app/_hiddenLayout/_authed/add-product'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
@@ -34,62 +35,67 @@ const AppHiddenLayoutRoute = AppHiddenLayoutRouteImport.update({
path: '/app', path: '/app',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AppHiddenLayoutViewProductRoute = const AppHiddenLayoutAuthedRoute = AppHiddenLayoutAuthedRouteImport.update({
AppHiddenLayoutViewProductRouteImport.update({ id: '/_authed',
id: '/view-product',
path: '/view-product',
getParentRoute: () => AppHiddenLayoutRoute,
} as any)
const AppHiddenLayoutStoragesRoute = AppHiddenLayoutStoragesRouteImport.update({
id: '/storages',
path: '/storages',
getParentRoute: () => AppHiddenLayoutRoute, getParentRoute: () => AppHiddenLayoutRoute,
} as any) } as any)
const AppHiddenLayoutInventoryRoute =
AppHiddenLayoutInventoryRouteImport.update({
id: '/inventory',
path: '/inventory',
getParentRoute: () => AppHiddenLayoutRoute,
} as any)
const AppHiddenLayoutAppSettingsRoute =
AppHiddenLayoutAppSettingsRouteImport.update({
id: '/app-settings',
path: '/app-settings',
getParentRoute: () => AppHiddenLayoutRoute,
} as any)
const AppHiddenLayoutAddProductRoute =
AppHiddenLayoutAddProductRouteImport.update({
id: '/add-product',
path: '/add-product',
getParentRoute: () => AppHiddenLayoutRoute,
} as any)
const AppHiddenLayoutQuickViewProductRoute = const AppHiddenLayoutQuickViewProductRoute =
AppHiddenLayoutQuickViewProductRouteImport.update({ AppHiddenLayoutQuickViewProductRouteImport.update({
id: '/quick-view/product', id: '/quick-view/product',
path: '/quick-view/product', path: '/quick-view/product',
getParentRoute: () => AppHiddenLayoutRoute, getParentRoute: () => AppHiddenLayoutRoute,
} as any) } as any)
const AppHiddenLayoutAuthedViewProductRoute =
AppHiddenLayoutAuthedViewProductRouteImport.update({
id: '/view-product',
path: '/view-product',
getParentRoute: () => AppHiddenLayoutAuthedRoute,
} as any)
const AppHiddenLayoutAuthedStoragesRoute =
AppHiddenLayoutAuthedStoragesRouteImport.update({
id: '/storages',
path: '/storages',
getParentRoute: () => AppHiddenLayoutAuthedRoute,
} as any)
const AppHiddenLayoutAuthedInventoryRoute =
AppHiddenLayoutAuthedInventoryRouteImport.update({
id: '/inventory',
path: '/inventory',
getParentRoute: () => AppHiddenLayoutAuthedRoute,
} as any)
const AppHiddenLayoutAuthedAppSettingsRoute =
AppHiddenLayoutAuthedAppSettingsRouteImport.update({
id: '/app-settings',
path: '/app-settings',
getParentRoute: () => AppHiddenLayoutAuthedRoute,
} as any)
const AppHiddenLayoutAuthedAddProductRoute =
AppHiddenLayoutAuthedAddProductRouteImport.update({
id: '/add-product',
path: '/add-product',
getParentRoute: () => AppHiddenLayoutAuthedRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/app': typeof AppHiddenLayoutRouteWithChildren '/app': typeof AppHiddenLayoutAuthedRouteWithChildren
'/app/add-product': typeof AppHiddenLayoutAddProductRoute '/app/add-product': typeof AppHiddenLayoutAuthedAddProductRoute
'/app/app-settings': typeof AppHiddenLayoutAppSettingsRoute '/app/app-settings': typeof AppHiddenLayoutAuthedAppSettingsRoute
'/app/inventory': typeof AppHiddenLayoutInventoryRoute '/app/inventory': typeof AppHiddenLayoutAuthedInventoryRoute
'/app/storages': typeof AppHiddenLayoutStoragesRoute '/app/storages': typeof AppHiddenLayoutAuthedStoragesRoute
'/app/view-product': typeof AppHiddenLayoutViewProductRoute '/app/view-product': typeof AppHiddenLayoutAuthedViewProductRoute
'/app/quick-view/product': typeof AppHiddenLayoutQuickViewProductRoute '/app/quick-view/product': typeof AppHiddenLayoutQuickViewProductRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/app': typeof AppHiddenLayoutRouteWithChildren '/app': typeof AppHiddenLayoutAuthedRouteWithChildren
'/app/add-product': typeof AppHiddenLayoutAddProductRoute '/app/add-product': typeof AppHiddenLayoutAuthedAddProductRoute
'/app/app-settings': typeof AppHiddenLayoutAppSettingsRoute '/app/app-settings': typeof AppHiddenLayoutAuthedAppSettingsRoute
'/app/inventory': typeof AppHiddenLayoutInventoryRoute '/app/inventory': typeof AppHiddenLayoutAuthedInventoryRoute
'/app/storages': typeof AppHiddenLayoutStoragesRoute '/app/storages': typeof AppHiddenLayoutAuthedStoragesRoute
'/app/view-product': typeof AppHiddenLayoutViewProductRoute '/app/view-product': typeof AppHiddenLayoutAuthedViewProductRoute
'/app/quick-view/product': typeof AppHiddenLayoutQuickViewProductRoute '/app/quick-view/product': typeof AppHiddenLayoutQuickViewProductRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -97,11 +103,12 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/app/_hiddenLayout': typeof AppHiddenLayoutRouteWithChildren '/app/_hiddenLayout': typeof AppHiddenLayoutRouteWithChildren
'/app/_hiddenLayout/add-product': typeof AppHiddenLayoutAddProductRoute '/app/_hiddenLayout/_authed': typeof AppHiddenLayoutAuthedRouteWithChildren
'/app/_hiddenLayout/app-settings': typeof AppHiddenLayoutAppSettingsRoute '/app/_hiddenLayout/_authed/add-product': typeof AppHiddenLayoutAuthedAddProductRoute
'/app/_hiddenLayout/inventory': typeof AppHiddenLayoutInventoryRoute '/app/_hiddenLayout/_authed/app-settings': typeof AppHiddenLayoutAuthedAppSettingsRoute
'/app/_hiddenLayout/storages': typeof AppHiddenLayoutStoragesRoute '/app/_hiddenLayout/_authed/inventory': typeof AppHiddenLayoutAuthedInventoryRoute
'/app/_hiddenLayout/view-product': typeof AppHiddenLayoutViewProductRoute '/app/_hiddenLayout/_authed/storages': typeof AppHiddenLayoutAuthedStoragesRoute
'/app/_hiddenLayout/_authed/view-product': typeof AppHiddenLayoutAuthedViewProductRoute
'/app/_hiddenLayout/quick-view/product': typeof AppHiddenLayoutQuickViewProductRoute '/app/_hiddenLayout/quick-view/product': typeof AppHiddenLayoutQuickViewProductRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -132,11 +139,12 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/app/_hiddenLayout' | '/app/_hiddenLayout'
| '/app/_hiddenLayout/add-product' | '/app/_hiddenLayout/_authed'
| '/app/_hiddenLayout/app-settings' | '/app/_hiddenLayout/_authed/add-product'
| '/app/_hiddenLayout/inventory' | '/app/_hiddenLayout/_authed/app-settings'
| '/app/_hiddenLayout/storages' | '/app/_hiddenLayout/_authed/inventory'
| '/app/_hiddenLayout/view-product' | '/app/_hiddenLayout/_authed/storages'
| '/app/_hiddenLayout/_authed/view-product'
| '/app/_hiddenLayout/quick-view/product' | '/app/_hiddenLayout/quick-view/product'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -169,39 +177,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppHiddenLayoutRouteImport preLoaderRoute: typeof AppHiddenLayoutRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/app/_hiddenLayout/view-product': { '/app/_hiddenLayout/_authed': {
id: '/app/_hiddenLayout/view-product' id: '/app/_hiddenLayout/_authed'
path: '/view-product' path: ''
fullPath: '/app/view-product' fullPath: '/app'
preLoaderRoute: typeof AppHiddenLayoutViewProductRouteImport preLoaderRoute: typeof AppHiddenLayoutAuthedRouteImport
parentRoute: typeof AppHiddenLayoutRoute
}
'/app/_hiddenLayout/storages': {
id: '/app/_hiddenLayout/storages'
path: '/storages'
fullPath: '/app/storages'
preLoaderRoute: typeof AppHiddenLayoutStoragesRouteImport
parentRoute: typeof AppHiddenLayoutRoute
}
'/app/_hiddenLayout/inventory': {
id: '/app/_hiddenLayout/inventory'
path: '/inventory'
fullPath: '/app/inventory'
preLoaderRoute: typeof AppHiddenLayoutInventoryRouteImport
parentRoute: typeof AppHiddenLayoutRoute
}
'/app/_hiddenLayout/app-settings': {
id: '/app/_hiddenLayout/app-settings'
path: '/app-settings'
fullPath: '/app/app-settings'
preLoaderRoute: typeof AppHiddenLayoutAppSettingsRouteImport
parentRoute: typeof AppHiddenLayoutRoute
}
'/app/_hiddenLayout/add-product': {
id: '/app/_hiddenLayout/add-product'
path: '/add-product'
fullPath: '/app/add-product'
preLoaderRoute: typeof AppHiddenLayoutAddProductRouteImport
parentRoute: typeof AppHiddenLayoutRoute parentRoute: typeof AppHiddenLayoutRoute
} }
'/app/_hiddenLayout/quick-view/product': { '/app/_hiddenLayout/quick-view/product': {
@@ -211,24 +191,72 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppHiddenLayoutQuickViewProductRouteImport preLoaderRoute: typeof AppHiddenLayoutQuickViewProductRouteImport
parentRoute: typeof AppHiddenLayoutRoute parentRoute: typeof AppHiddenLayoutRoute
} }
'/app/_hiddenLayout/_authed/view-product': {
id: '/app/_hiddenLayout/_authed/view-product'
path: '/view-product'
fullPath: '/app/view-product'
preLoaderRoute: typeof AppHiddenLayoutAuthedViewProductRouteImport
parentRoute: typeof AppHiddenLayoutAuthedRoute
}
'/app/_hiddenLayout/_authed/storages': {
id: '/app/_hiddenLayout/_authed/storages'
path: '/storages'
fullPath: '/app/storages'
preLoaderRoute: typeof AppHiddenLayoutAuthedStoragesRouteImport
parentRoute: typeof AppHiddenLayoutAuthedRoute
}
'/app/_hiddenLayout/_authed/inventory': {
id: '/app/_hiddenLayout/_authed/inventory'
path: '/inventory'
fullPath: '/app/inventory'
preLoaderRoute: typeof AppHiddenLayoutAuthedInventoryRouteImport
parentRoute: typeof AppHiddenLayoutAuthedRoute
}
'/app/_hiddenLayout/_authed/app-settings': {
id: '/app/_hiddenLayout/_authed/app-settings'
path: '/app-settings'
fullPath: '/app/app-settings'
preLoaderRoute: typeof AppHiddenLayoutAuthedAppSettingsRouteImport
parentRoute: typeof AppHiddenLayoutAuthedRoute
}
'/app/_hiddenLayout/_authed/add-product': {
id: '/app/_hiddenLayout/_authed/add-product'
path: '/add-product'
fullPath: '/app/add-product'
preLoaderRoute: typeof AppHiddenLayoutAuthedAddProductRouteImport
parentRoute: typeof AppHiddenLayoutAuthedRoute
}
} }
} }
interface AppHiddenLayoutAuthedRouteChildren {
AppHiddenLayoutAuthedAddProductRoute: typeof AppHiddenLayoutAuthedAddProductRoute
AppHiddenLayoutAuthedAppSettingsRoute: typeof AppHiddenLayoutAuthedAppSettingsRoute
AppHiddenLayoutAuthedInventoryRoute: typeof AppHiddenLayoutAuthedInventoryRoute
AppHiddenLayoutAuthedStoragesRoute: typeof AppHiddenLayoutAuthedStoragesRoute
AppHiddenLayoutAuthedViewProductRoute: typeof AppHiddenLayoutAuthedViewProductRoute
}
const AppHiddenLayoutAuthedRouteChildren: AppHiddenLayoutAuthedRouteChildren = {
AppHiddenLayoutAuthedAddProductRoute: AppHiddenLayoutAuthedAddProductRoute,
AppHiddenLayoutAuthedAppSettingsRoute: AppHiddenLayoutAuthedAppSettingsRoute,
AppHiddenLayoutAuthedInventoryRoute: AppHiddenLayoutAuthedInventoryRoute,
AppHiddenLayoutAuthedStoragesRoute: AppHiddenLayoutAuthedStoragesRoute,
AppHiddenLayoutAuthedViewProductRoute: AppHiddenLayoutAuthedViewProductRoute,
}
const AppHiddenLayoutAuthedRouteWithChildren =
AppHiddenLayoutAuthedRoute._addFileChildren(
AppHiddenLayoutAuthedRouteChildren,
)
interface AppHiddenLayoutRouteChildren { interface AppHiddenLayoutRouteChildren {
AppHiddenLayoutAddProductRoute: typeof AppHiddenLayoutAddProductRoute AppHiddenLayoutAuthedRoute: typeof AppHiddenLayoutAuthedRouteWithChildren
AppHiddenLayoutAppSettingsRoute: typeof AppHiddenLayoutAppSettingsRoute
AppHiddenLayoutInventoryRoute: typeof AppHiddenLayoutInventoryRoute
AppHiddenLayoutStoragesRoute: typeof AppHiddenLayoutStoragesRoute
AppHiddenLayoutViewProductRoute: typeof AppHiddenLayoutViewProductRoute
AppHiddenLayoutQuickViewProductRoute: typeof AppHiddenLayoutQuickViewProductRoute AppHiddenLayoutQuickViewProductRoute: typeof AppHiddenLayoutQuickViewProductRoute
} }
const AppHiddenLayoutRouteChildren: AppHiddenLayoutRouteChildren = { const AppHiddenLayoutRouteChildren: AppHiddenLayoutRouteChildren = {
AppHiddenLayoutAddProductRoute: AppHiddenLayoutAddProductRoute, AppHiddenLayoutAuthedRoute: AppHiddenLayoutAuthedRouteWithChildren,
AppHiddenLayoutAppSettingsRoute: AppHiddenLayoutAppSettingsRoute,
AppHiddenLayoutInventoryRoute: AppHiddenLayoutInventoryRoute,
AppHiddenLayoutStoragesRoute: AppHiddenLayoutStoragesRoute,
AppHiddenLayoutViewProductRoute: AppHiddenLayoutViewProductRoute,
AppHiddenLayoutQuickViewProductRoute: AppHiddenLayoutQuickViewProductRoute, AppHiddenLayoutQuickViewProductRoute: AppHiddenLayoutQuickViewProductRoute,
} }
+9 -2
View File
@@ -1,4 +1,4 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Outlet, useRouterState } from "@tanstack/react-router";
import { Sidebar } from "../../components/Sidebar"; import { Sidebar } from "../../components/Sidebar";
export const Route = createFileRoute("/app/_hiddenLayout")({ export const Route = createFileRoute("/app/_hiddenLayout")({
@@ -6,11 +6,18 @@ export const Route = createFileRoute("/app/_hiddenLayout")({
}); });
function AppLayout() { function AppLayout() {
const pathname = useRouterState({ select: (state) => state.location.pathname });
return ( return (
<div className="flex min-h-screen w-full flex-col bg-[#f7f9fc] lg:flex-row"> <div
className="flex min-h-screen w-full flex-col lg:flex-row"
style={{ backgroundColor: "var(--joy-palette-background-body)" }}
>
<Sidebar /> <Sidebar />
<main className="flex-1 px-4 py-5 sm:px-6 lg:px-8 lg:py-6"> <main className="flex-1 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
<div key={pathname} className="animate-page-in">
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
); );
@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { verifyLogin } from "../../../utils/api/auth";
export const Route = createFileRoute("/app/_hiddenLayout/_authed")({
beforeLoad: async () => {
await verifyLogin();
},
component: () => <Outlet />,
});
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { AddProduct } from "../../../../pages/AddProduct";
export const Route = createFileRoute("/app/_hiddenLayout/_authed/add-product")({
component: RouteComponent,
});
function RouteComponent() {
return <AddProduct />;
}
@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { Settings } from "../../../../pages/Settings";
export const Route = createFileRoute("/app/_hiddenLayout/_authed/app-settings")(
{
component: RouteComponent,
},
);
function RouteComponent() {
return <Settings />;
}
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { InventoryPage } from "../../../../pages/Inventory";
export const Route = createFileRoute("/app/_hiddenLayout/_authed/inventory")({
component: RouteComponent,
});
function RouteComponent() {
return <InventoryPage />;
}
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { Storages } from "../../../../pages/Storages";
export const Route = createFileRoute("/app/_hiddenLayout/_authed/storages")({
component: RouteComponent,
});
function RouteComponent() {
return <Storages />;
}
@@ -0,0 +1,17 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { ViewProduct } from "../../../../pages/ViewProduct";
export const Route = createFileRoute("/app/_hiddenLayout/_authed/view-product")(
{
validateSearch: z.object({
product: z.string(),
}),
component: RouteComponent,
},
);
function RouteComponent() {
const { product } = Route.useSearch();
return <ViewProduct uuid={product} />;
}
@@ -1,18 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { isAuthenticated } from "../../../utils/api/auth";
import { AddProduct } from "../../../pages/AddProduct";
export const Route = createFileRoute("/app/_hiddenLayout/add-product")({
beforeLoad: async () => {
if (!(await isAuthenticated())) {
throw redirect({
to: "/login",
});
}
},
component: RouteComponent,
});
function RouteComponent() {
return <AddProduct />;
}
@@ -1,18 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { isAuthenticated } from "../../../utils/api/auth";
import { Settings } from "../../../pages/Settings";
export const Route = createFileRoute("/app/_hiddenLayout/app-settings")({
beforeLoad: async () => {
if (!(await isAuthenticated())) {
throw redirect({
to: "/login",
});
}
},
component: RouteComponent,
});
function RouteComponent() {
return <Settings />;
}
@@ -1,18 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { isAuthenticated } from "../../../utils/api/auth";
import { InventoryPage } from "../../../pages/Inventory";
export const Route = createFileRoute("/app/_hiddenLayout/inventory")({
beforeLoad: async () => {
if (!(await isAuthenticated())) {
throw redirect({
to: "/login",
});
}
},
component: RouteComponent,
});
function RouteComponent() {
return <InventoryPage />;
}
@@ -1,18 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { isAuthenticated } from "../../../utils/api/auth";
import { Storages } from "../../../pages/Storages";
export const Route = createFileRoute("/app/_hiddenLayout/storages")({
beforeLoad: async () => {
if (!(await isAuthenticated())) {
throw redirect({
to: "/login",
});
}
},
component: RouteComponent,
});
function RouteComponent() {
return <Storages />;
}
@@ -1,23 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { z } from "zod";
import { isAuthenticated } from "../../../utils/api/auth";
import { ViewProduct } from "../../../pages/ViewProduct";
export const Route = createFileRoute("/app/_hiddenLayout/view-product")({
beforeLoad: async () => {
if (!(await isAuthenticated())) {
throw redirect({
to: "/login",
});
}
},
validateSearch: z.object({
product: z.string(),
}),
component: RouteComponent,
});
function RouteComponent() {
const { product } = Route.useSearch();
return <ViewProduct uuid={product} />;
}
+1
View File
@@ -6,6 +6,7 @@ export const Route = createFileRoute("/")({
if (!(await isAuthenticated())) { if (!(await isAuthenticated())) {
throw redirect({ throw redirect({
to: "/login", to: "/login",
search: { loggedOut: true },
}); });
} else { } else {
throw redirect({ throw redirect({
+4
View File
@@ -1,7 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { LoginCard } from "../components/LoginCard"; import { LoginCard } from "../components/LoginCard";
import { checkLogin } from "../utils/api/auth.ts";
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
beforeLoad: async () => {
await checkLogin();
},
component: RouteComponent, component: RouteComponent,
}); });
+329
View File
@@ -0,0 +1,329 @@
import { extendTheme } from "@mui/joy/styles";
// Palette tokens sourced from the v0.9 design mockups (canvas/surface/ink/brand/etc.)
export const theme = extendTheme({
cssVarPrefix: "joy",
colorSchemes: {
light: {
palette: {
primary: {
50: "#eef3fe",
100: "#dbe6fc",
200: "#b0c8f8",
300: "#7fa3f0",
400: "#4c7de0",
500: "#2159c9",
600: "#1c48a3",
700: "#163a82",
800: "#122e68",
900: "#0e2453",
solidBg: "#2159c9",
solidHoverBg: "#1c48a3",
solidActiveBg: "#163a82",
outlinedBorder: "#7fa3f0",
outlinedColor: "#163a82",
softBg: "#eef3fe", // Color in sidebar for selected view
softColor: "#163a82",
},
success: {
50: "#ecfdf5",
100: "#d1faee",
200: "#a6f4dc",
300: "#6ee7c9",
400: "#34d0ac",
500: "#0d9488",
600: "#0b7c73",
700: "#0a655e",
800: "#0b514c",
900: "#0a423f",
solidBg: "#0d9488",
solidHoverBg: "#0b7c73",
outlinedBorder: "#a6f4dc",
outlinedColor: "#0b7c73",
softBg: "#d1faee",
softColor: "#0a655e",
},
warning: {
50: "#fdf1e0",
100: "#fbe3c1",
200: "#f6c98a",
300: "#eeab54",
400: "#e2932c",
500: "#d98014",
600: "#b46810",
700: "#92530f",
800: "#734111",
900: "#5c350f",
solidBg: "#d98014",
solidHoverBg: "#b46810",
outlinedBorder: "#f6c98a",
outlinedColor: "#b46810",
softBg: "#fdf1e0",
softColor: "#734111",
},
danger: {
50: "#fbeae9",
100: "#f6d2cf",
200: "#eaa39c",
300: "#dc746a",
400: "#cd4f42",
500: "#c4342c",
600: "#a32a24",
700: "#84221f",
800: "#691c1b",
900: "#551818",
solidBg: "#c4342c",
solidHoverBg: "#a32a24",
outlinedBorder: "#eaa39c",
outlinedColor: "#a32a24",
softBg: "#fbeae9",
softColor: "#84221f",
},
neutral: {
50: "#f6f7f9",
100: "#f2f4f6",
200: "#e4e7ec",
300: "#dfe3e8",
400: "#7b8494",
500: "#667283",
600: "#4a5364",
700: "#39414f",
800: "#232833",
900: "#10151f",
plainColor: "#4a5364",
outlinedColor: "#4a5364",
outlinedBorder: "#e4e7ec",
outlinedHoverBg: "#fbfcfd",
softColor: "#4a5364",
softBg: "#f2f4f6",
},
background: {
body: "#f6f7f9",
surface: "#ffffff",
level1: "#fbfcfd",
level2: "#f2f4f6",
level3: "#e4e7ec",
},
text: {
primary: "#10151f",
secondary: "#4a5364",
tertiary: "#667283",
},
divider: "#e4e7ec",
},
},
dark: {
palette: {
primary: {
50: "#0e2453",
100: "#122e68",
200: "#163a82",
300: "#1c48a3",
400: "#2159c9",
500: "#5b8def",
600: "#82a8f2",
700: "#a9c3f6",
800: "#cddcfa",
900: "#eef3fe",
solidBg: "#5b8def",
solidHoverBg: "#82a8f2",
solidActiveBg: "#1c48a3",
outlinedBorder: "#1c48a3",
outlinedColor: "#a9c3f6",
softBg: "#16233d", // Color in sidebar for selected view
softColor: "#a9c3f6",
},
success: {
50: "#0a423f",
100: "#0b514c",
200: "#0a655e",
300: "#0b7c73",
400: "#0d9488",
500: "#2dc8b1",
600: "#6ee7c9",
700: "#a6f4dc",
800: "#d1faee",
900: "#ecfdf5",
solidBg: "#0d9488",
solidHoverBg: "#2dc8b1",
outlinedBorder: "#0a655e",
outlinedColor: "#6ee7c9",
softBg: "rgba(13, 148, 136, 0.18)",
softColor: "#6ee7c9",
},
warning: {
50: "#5c350f",
100: "#734111",
200: "#92530f",
300: "#b46810",
400: "#d98014",
500: "#e3a054",
600: "#edbb82",
700: "#f4d3ac",
800: "#f9e6d3",
900: "#fdf1e0",
solidBg: "#e3a054",
solidHoverBg: "#edbb82",
outlinedBorder: "#92530f",
outlinedColor: "#edbb82",
softBg: "rgba(227, 160, 84, 0.16)",
softColor: "#f4d3ac",
},
danger: {
50: "#551818",
100: "#691c1b",
200: "#84221f",
300: "#a32a24",
400: "#c4342c",
500: "#f07168",
600: "#f4948d",
700: "#f8b7b2",
800: "#fbd6d3",
900: "#fdeceb",
solidBg: "#f07168",
solidHoverBg: "#f4948d",
outlinedBorder: "#84221f",
outlinedColor: "#f4948d",
softBg: "rgba(240, 113, 104, 0.16)",
softColor: "#f8b7b2",
},
neutral: {
50: "#0e1116",
100: "#161b22",
200: "#1b212a",
300: "#20262f",
400: "#272e39",
500: "#8590a0",
600: "#aab4c2",
700: "#c7cfd9",
800: "#e8ecf2",
900: "#f6f8fa",
plainColor: "#aab4c2",
outlinedColor: "#aab4c2",
outlinedBorder: "#272e39",
outlinedHoverBg: "#1b212a",
softColor: "#aab4c2",
softBg: "#20262f",
},
background: {
body: "#0e1116",
surface: "#161b22",
level1: "#1b212a",
level2: "#20262f",
level3: "#272e39",
},
text: {
primary: "#e8ecf2",
secondary: "#aab4c2",
tertiary: "#8590a0",
},
divider: "#272e39",
},
},
},
fontFamily: {
body: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif",
display: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif",
code: "'IBM Plex Mono', ui-monospace, monospace",
},
radius: {
xs: "6px",
sm: "10px",
md: "14px",
lg: "20px",
xl: "28px",
},
typography: {
h1: {
fontSize: "30px",
fontWeight: 700,
letterSpacing: "-0.03em",
lineHeight: 1.2,
},
h2: {
fontSize: "22px",
fontWeight: 700,
letterSpacing: "-0.02em",
lineHeight: 1.25,
},
"title-lg": {
fontSize: "15px",
fontWeight: 600,
lineHeight: 1.4,
},
"title-md": {
fontSize: "13px",
fontWeight: 600,
lineHeight: 1.4,
},
"body-lg": {
fontSize: "13.5px",
fontWeight: 400,
lineHeight: 1.5,
},
"body-md": {
fontSize: "13.5px",
fontWeight: 400,
lineHeight: 1.5,
},
"body-sm": {
fontSize: "11.5px",
fontWeight: 600,
lineHeight: 1.4,
},
},
components: {
JoyInput: {
styleOverrides: {
input: ({ theme }) => ({
color: theme.vars.palette.text.primary,
"&::placeholder": {
color: theme.vars.palette.text.tertiary,
opacity: 1,
},
}),
},
},
JoySelect: {
styleOverrides: {
button: ({ theme }) => ({
color: theme.vars.palette.text.primary,
}),
listbox: ({ theme }) => ({
color: theme.vars.palette.text.primary,
backgroundColor: theme.vars.palette.background.surface,
}),
},
},
JoySheet: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: theme.vars.palette.background.surface,
borderColor: theme.vars.palette.divider,
}),
},
},
JoyOption: {
styleOverrides: {
root: ({ theme }) => ({
color: theme.vars.palette.text.primary,
backgroundColor: theme.vars.palette.background.surface,
"&:hover": {
backgroundColor: theme.vars.palette.background.level1,
},
'&[aria-selected="true"]': {
backgroundColor: theme.vars.palette.background.level2,
},
}),
},
},
JoyButton: {
styleOverrides: {
root: {
transition: "all 0.15s ease",
},
},
},
},
});
+24 -11
View File
@@ -4,8 +4,9 @@ import type { TFunction } from "i18next";
import { fetchSettings } from "./settings"; import { fetchSettings } from "./settings";
import type { ChangePasswordIntf } from "../../misc/interfaces"; import type { ChangePasswordIntf } from "../../misc/interfaces";
import { createApiError } from "./apiError"; import { createApiError } from "./apiError";
import { redirect } from "@tanstack/react-router";
export async function isAuthenticated() { export const isAuthenticated = async () => {
if (Cookies.get("token")) { if (Cookies.get("token")) {
const result = await fetch(`${API_BASE}/users/verify-token`, { const result = await fetch(`${API_BASE}/users/verify-token`, {
method: "POST", method: "POST",
@@ -23,13 +24,13 @@ export async function isAuthenticated() {
Cookies.remove("token"); Cookies.remove("token");
return false; return false;
} };
export async function signInUser( export const signInUser = async (
username: string, username: string,
password: string, password: string,
t: TFunction, t: TFunction,
) { ) => {
const result = await fetch(`${API_BASE}/users/login`, { const result = await fetch(`${API_BASE}/users/login`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -54,12 +55,7 @@ export async function signInUser(
Cookies.remove("token"); Cookies.remove("token");
throw createApiError(response.code, t(response.code || "unknown-error")); throw createApiError(response.code, t(response.code || "unknown-error"));
} };
export function signOutUser() {
Cookies.remove("token");
return { ok: true as const };
}
export const mutatePassword = async (payload: ChangePasswordIntf) => { export const mutatePassword = async (payload: ChangePasswordIntf) => {
const result = await fetch(`${API_BASE}/users/change-password`, { const result = await fetch(`${API_BASE}/users/change-password`, {
@@ -77,9 +73,26 @@ export const mutatePassword = async (payload: ChangePasswordIntf) => {
const response = await result.json(); const response = await result.json();
if (response.code === "su005") { if (response.code === "SU005") {
return { code: response.code }; return { code: response.code };
} }
throw createApiError(response.code, "Change password failed"); throw createApiError(response.code, "Change password failed");
}; };
export const verifyLogin = async () => {
if (!(await isAuthenticated())) {
throw redirect({
to: "/login",
search: { loggedOut: true },
});
}
};
export const checkLogin = async () => {
if (await isAuthenticated()) {
throw redirect({
to: "/app/inventory",
});
}
};
+9 -9
View File
@@ -1,7 +1,7 @@
import { API_BASE } from "../../config/api.config"; import {API_BASE} from "../../config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import type { ProductFormValues } from "../../misc/interfaces"; import type {ProductFormValues} from "../../misc/interfaces";
import { createApiError } from "./apiError"; import {createApiError} from "./apiError";
export const getProducts = async () => { export const getProducts = async () => {
const result = await fetch(`${API_BASE}/products/all-products`, { const result = await fetch(`${API_BASE}/products/all-products`, {
@@ -13,7 +13,7 @@ export const getProducts = async () => {
}); });
const response = await result.json(); const response = await result.json();
if (response.code === "sp002") { if (response.code === "SP002") {
return response.data; return response.data;
} }
@@ -31,7 +31,7 @@ export const getProductDetails = async (uuid: string) => {
const response = await result.json(); const response = await result.json();
if (response.code === "sp003") { if (response.code === "SP003") {
return response.data; return response.data;
} }
@@ -63,7 +63,7 @@ export const mutateProduct = async (
const response = await result.json(); const response = await result.json();
if (response.code === "sp005") { if (response.code === "SP005") {
return response.data; return response.data;
} }
@@ -83,8 +83,8 @@ export const deleteSelectedProducts = async (uuids: string[]) => {
const response = await result.json(); const response = await result.json();
if (response.code === "sp006") { if (response.code === "SP006") {
return { success: true }; return {success: true};
} }
throw createApiError(response.code, "Delete products failed"); throw createApiError(response.code, "Delete products failed");
@@ -113,7 +113,7 @@ export const createProduct = async (values: ProductFormValues) => {
const response = await result.json(); const response = await result.json();
if (response.code === "sp001") { if (response.code === "SP001") {
return response.data; return response.data;
} }
+7 -7
View File
@@ -1,7 +1,7 @@
import { API_BASE } from "../../config/api.config"; import {API_BASE} from "../../config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import type { SettingsIntf } from "../../misc/interfaces"; import type {SettingsIntf} from "../../misc/interfaces";
import { createApiError } from "./apiError"; import {createApiError} from "./apiError";
export const fetchSettings = async () => { export const fetchSettings = async () => {
const result = await fetch(`${API_BASE}/users/settings`, { const result = await fetch(`${API_BASE}/users/settings`, {
@@ -15,8 +15,8 @@ export const fetchSettings = async () => {
const response = await result.json(); const response = await result.json();
if (response.code === "su004") { if (response.code === "SU004") {
return { success: true, data: response.data, code: response.code }; return {success: true, data: response.data, code: response.code};
} }
throw createApiError(response.code, "Fetch settings failed"); throw createApiError(response.code, "Fetch settings failed");
@@ -35,8 +35,8 @@ export const mutateSettings = async (payload: SettingsIntf) => {
const response = await result.json(); const response = await result.json();
if (response.code === "su003") { if (response.code === "SU003") {
return { success: true, code: response.code }; return {success: true, code: response.code};
} }
throw createApiError(response.code, "Update settings failed"); throw createApiError(response.code, "Update settings failed");
+7 -7
View File
@@ -1,7 +1,7 @@
import { API_BASE } from "../../config/api.config"; import {API_BASE} from "../../config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import type { NewStorage, Storage } from "../../misc/interfaces"; import type {NewStorage, Storage} from "../../misc/interfaces";
import { createApiError } from "./apiError"; import {createApiError} from "./apiError";
export const getStorages = async () => { export const getStorages = async () => {
const result = await fetch(`${API_BASE}/storage/all-storages`, { const result = await fetch(`${API_BASE}/storage/all-storages`, {
@@ -14,7 +14,7 @@ export const getStorages = async () => {
const response = await result.json(); const response = await result.json();
if (response.code === "ss001") { if (response.code === "SS001") {
return response.data; return response.data;
} }
@@ -34,7 +34,7 @@ export const mutateNewStorage = async (values: NewStorage) => {
const response = await result.json(); const response = await result.json();
if (response.code === "ss002") { if (response.code === "SS002") {
return response.data; return response.data;
} }
@@ -79,8 +79,8 @@ export const deleteStorage = async (uuid: string) => {
const response = await result.json(); const response = await result.json();
if (response.code === "ss004") { if (response.code === "SS004") {
return { success: true, code: response.code }; return {success: true, code: response.code};
} }
throw createApiError(response.code, "Delete storage failed"); throw createApiError(response.code, "Delete storage failed");
+76 -2
View File
@@ -6,7 +6,6 @@
"details": "Details", "details": "Details",
"username": "Benutzername", "username": "Benutzername",
"password": "Passwort", "password": "Passwort",
"eu001": "Falscher Benutzername oder Passwort!",
"login": "Login", "login": "Login",
"product-name": "Produktname", "product-name": "Produktname",
"price": "Preis", "price": "Preis",
@@ -57,5 +56,80 @@
"change": "Ändern", "change": "Ändern",
"error": "Fehler", "error": "Fehler",
"success": "Erfolg", "success": "Erfolg",
"submit": "Absenden" "submit": "Absenden",
"logout-success-text": "Logout erfolgreich!",
"not-found-header": "404 - Nicht gefunden",
"not-found-body": "Die Seite nach der du gesucht hast, gibt es nicht.",
"SU005": "Das Passwort wurde erfolgreich geändert!",
"SE001": "Die Einstellungen wurden erfolgreich geändert!",
"EG001": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.",
"EU001": "Falscher Benutzername oder Passwort!",
"EU002": "Ihr Konto wurde deaktiviert. Bitte wenden Sie sich an einen Administrator.",
"EU003": "Bitte geben Sie Benutzername und Passwort ein.",
"EU004": "Einige erforderliche Angaben fehlen. Bitte überprüfen Sie Ihre Eingabe.",
"EU005": "Anmeldung aufgrund eines technischen Problems fehlgeschlagen. Bitte versuchen Sie es erneut.",
"EU006": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"EU007": "Passwort konnte nicht geändert werden. Bitte versuchen Sie es erneut.",
"EU008": "Ihre Einstellungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"EU009": "Ihre Einstellungen konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"EU010": "Sie müssen Ihr neues Passwort zweimal korrekt eingeben.",
"ES001": "Bitte füllen Sie alle erforderlichen Felder für den Lagerort aus.",
"ES002": "Einige erforderliche Lagerinformationen fehlen. Bitte überprüfen Sie Ihre Eingabe.",
"ES003": "Der Lagerort konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
"ES004": "Der Lagerort konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"ES005": "Keine Lagerorte gefunden.",
"ES006": "Der Lagerort konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"EP001": "Das Produkt konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"EP002": "Keine Produkte gefunden.",
"EP003": "Produkt nicht gefunden.",
"EP004": "Die Produktmenge konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
"EP005": "Das Produkt konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
"EP006": "Das Produkt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"EP007": "Bitte geben Sie mindestens einen Produktnamen ein.",
"EP008": "Einige erforderliche Produktinformationen fehlen. Bitte überprüfen Sie Ihre Eingabe.",
"stat-expired": "Abgelaufen",
"stat-expiring-soon": "Läuft in 90 Tagen ab",
"stat-low-stock": "Niedriger Bestand",
"stat-units-stored": "Gelagerte Einheiten",
"days-since-expired": "Vor {{count}} Tagen abgelaufen",
"days-until-expiry": "in {{count}} Tagen",
"expires-in-days-badge": "Läuft in {{count}} Tagen ab",
"general": "Allgemein",
"appearance-language": "Darstellung & Sprache",
"theme": "Design",
"theme-light": "Hell",
"theme-dark": "Dunkel",
"theme-system": "System",
"language": "Sprache",
"account": "Konto",
"sign-out": "Abmelden",
"sign-out-sub": "Beendet diese Sitzung auf diesem Gerät.",
"login-tagline-1": "Alles, was du verstaut hast, im Januar noch auffindbar.",
"login-tagline-2": "Gefrierfach, Vorrat, Keller. Eine Liste, ein Ablaufdatum pro Glas.",
"storages-sub": "{{count}} Orte, {{items}} Artikel gelagert.",
"new-storage": "Neuer Lagerort",
"product": "Produkt",
"product-name-placeholder": "z. B. Tomatensauce",
"description-placeholder": "Kurze Notiz zu dieser Charge",
"choose-storage": "Lagerort wählen",
"amount-price-optional-hint": "Menge und Preis sind optional. Der Lagerort bestimmt, wo der Artikel im Inventar erscheint.",
"all-fields-editable-later": "Alle Felder können später bearbeitet werden",
"save-product": "Produkt speichern",
"jar-label": "Glas-Etikett",
"jar-label-hint": "Ausdrucken und auf den Deckel kleben. Scannen öffnet die Schnellansicht.",
"scanned-badge": "Gescannt",
"opened-from-jar-label": "Über das Glas-Etikett geöffnet",
"amount-in-storage": "Menge im Lager",
"set-to-zero-hint": "Auf 0 setzen, um den Eintrag zu behalten, aber das Fach zu leeren.",
"open-full-details": "Vollständige Produktdetails öffnen",
"inventory-summary": "{{count}} Artikel in {{storages}} Lagerorten",
"search-products": "Produkte durchsuchen",
"filter-all": "Alle",
"filter-expired": "Abgelaufen",
"filter-soon": "Bald",
"filter-low": "Niedrig",
"products": "Produkte",
"units": "Einheiten",
"cancel": "Abbrechen",
"delete-confirmation-text": "Bist du dir sicher das du diesen Storage löschen willst: "
} }
+76 -2
View File
@@ -6,7 +6,6 @@
"details": "Details", "details": "Details",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"eu001": "Wrong username or password!",
"login": "Login", "login": "Login",
"product-name": "Product name", "product-name": "Product name",
"price": "Price", "price": "Price",
@@ -57,5 +56,80 @@
"change": "Change", "change": "Change",
"error": "Error", "error": "Error",
"success": "Success", "success": "Success",
"submit": "Submit" "submit": "Submit",
"logout-success-text": "Logout successful!",
"not-found-header": "404 - Not found",
"not-found-body": "The page you were looking for, does not exist.",
"SU005": "Your password is updated successfully!",
"SE001": "The settings are updated successfully!",
"EG001": "An unexpected error occurred. Please try again later.",
"EU001": "Wrong username or password!",
"EU002": "Your account has been deactivated. Please contact an administrator.",
"EU003": "Please enter both username and password.",
"EU004": "Some required information is missing. Please check your input.",
"EU005": "Login failed due to a technical issue. Please try again.",
"EU006": "Login failed. Please try again.",
"EU007": "Failed to change your password. Please try again.",
"EU008": "Your settings could not be saved. Please try again.",
"EU009": "Your settings could not be loaded. Please try again.",
"EU010": "You must enter your new password twice correctly.",
"ES001": "Please fill in all required fields for the storage location.",
"ES002": "Some required storage information is missing. Please check your input.",
"ES003": "The storage location could not be updated. Please try again.",
"ES004": "The storage location could not be deleted. Please try again.",
"ES005": "No storage locations found.",
"ES006": "The storage location could not be created. Please try again.",
"EP001": "The product could not be created. Please try again.",
"EP002": "No products found.",
"EP003": "Product not found.",
"EP004": "The product amount could not be updated. Please try again.",
"EP005": "The product could not be updated. Please try again.",
"EP006": "The product could not be deleted. Please try again.",
"EP007": "Please enter at least a product name.",
"EP008": "Some required product information is missing. Please check your input.",
"stat-expired": "Expired",
"stat-expiring-soon": "Expiring in 90 days",
"stat-low-stock": "Low stock",
"stat-units-stored": "Units stored",
"days-since-expired": "Expired {{count}} days ago",
"days-until-expiry": "in {{count}} days",
"expires-in-days-badge": "Expires in {{count}} days",
"general": "General",
"appearance-language": "Appearance & language",
"theme": "Theme",
"theme-light": "Light",
"theme-dark": "Dark",
"theme-system": "System",
"language": "Language",
"account": "Account",
"sign-out": "Sign out",
"sign-out-sub": "Ends this session on this device.",
"login-tagline-1": "Everything you put away, still findable in January.",
"login-tagline-2": "Freezer, pantry, cellar. One list, one expiry date per jar.",
"storages-sub": "{{count}} places, {{items}} items stored.",
"new-storage": "New storage",
"product": "Product",
"product-name-placeholder": "e.g. Tomato Sauce",
"description-placeholder": "Short note about this batch",
"choose-storage": "Choose a storage",
"amount-price-optional-hint": "Amount and price are optional. Storage place decides where the item shows up in the inventory list.",
"all-fields-editable-later": "All fields can be edited later",
"save-product": "Save product",
"jar-label": "Jar label",
"jar-label-hint": "Print and stick it on the lid. Scanning opens fast details.",
"scanned-badge": "Scanned",
"opened-from-jar-label": "Opened from the jar label",
"amount-in-storage": "Amount in storage",
"set-to-zero-hint": "Set to 0 to keep the record but empty the shelf.",
"open-full-details": "Open full product details",
"inventory-summary": "{{count}} items across {{storages}} storages",
"search-products": "Search products",
"filter-all": "All",
"filter-expired": "Expired",
"filter-soon": "Soon",
"filter-low": "Low",
"products": "products",
"units": "units",
"cancel": "Cancel",
"delete-confirmation-text": "Are you sure you want to delete this storage: "
} }
+53
View File
@@ -0,0 +1,53 @@
const EXPIRING_SOON_DAYS = 90;
const LOW_STOCK_MAX = 2;
const daysUntil = (value?: string | null) => {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
date.setHours(0, 0, 0, 0);
return Math.round((date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
};
export const isExpired = (expiryDate?: string | null) => {
const days = daysUntil(expiryDate);
return days !== null && days < 0;
};
export const isExpiringSoon = (expiryDate?: string | null) => {
const days = daysUntil(expiryDate);
return days !== null && days >= 0 && days <= EXPIRING_SOON_DAYS;
};
export const isLowStock = (amount: number) => amount > 0 && amount <= LOW_STOCK_MAX;
export type ProductStatus = "expired" | "expiring-soon" | "ok";
export const getProductStatus = (expiryDate?: string | null): ProductStatus => {
if (isExpired(expiryDate)) {
return "expired";
}
if (isExpiringSoon(expiryDate)) {
return "expiring-soon";
}
return "ok";
};
// Days remaining until expiry (negative = days since it expired), plus the
// derived status. Components format this into copy via i18next (t()) so the
// wording stays translatable.
export const getExpiryDays = (
expiryDate?: string | null,
): { days: number; status: ProductStatus } | null => {
const days = daysUntil(expiryDate);
if (days === null) {
return null;
}
return { days, status: getProductStatus(expiryDate) };
};
+4 -1
View File
@@ -19,7 +19,10 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true
}, },
"include": ["src"] "include": ["src"]
} }
+23 -4
View File
@@ -1,7 +1,26 @@
{ {
"files": [], "compilerOptions": {
"references": [ "target": "ES2022",
{ "path": "./tsconfig.app.json" }, "useDefineForClassFields": true,
{ "path": "./tsconfig.node.json" } "lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": [
"vite/client"
]
},
"include": [
"src"
] ]
} }
+2 -2
View File
@@ -2,14 +2,14 @@ import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel"; import babel from "@rolldown/plugin-babel";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; import { tanstackRouter } from "@tanstack/router-vite-plugin";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss(),
TanStackRouterVite(), tanstackRouter(),
babel({ presets: [reactCompilerPreset()] }), babel({ presets: [reactCompilerPreset()] }),
], ],
}); });
+1333 -597
View File
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -3,17 +3,21 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"backend", "backend",
"frontend" "frontend",
"shared"
], ],
"version": "0.1.0-dev", "version": "0.1.0-dev",
"description": "![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=000&style=flat) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff&style=flat) ![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=fff&style=flat) ![Tailwind%20CSS](https://img.shields.io/badge/Tailwind%20CSS-38B2AC?logo=tailwindcss&logoColor=fff&style=flat) ![MUI](https://img.shields.io/badge/MUI-007FFF?logo=mui&logoColor=fff&style=flat) ![TanStack%20Query](https://img.shields.io/badge/TanStack%20Query-FF4154?logo=reactquery&logoColor=fff&style=flat) ![React%20Router](https://img.shields.io/badge/React%20Router-CA4245?logo=reactrouter&logoColor=fff&style=flat) [![TanStack%20Form](https://img.shields.io/badge/TanStack%20Form-EC5990?logo=reacthookform&logoColor=fff)](#) ![Node.js](https://img.shields.io/badge/Node.js-339933?logo=nodedotjs&logoColor=fff&style=flat) ![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=fff&style=flat) ![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=fff&style=flat) ![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=fff&style=flat) ![Nginx](https://img.shields.io/badge/Nginx-009639?logo=nginx&logoColor=fff&style=flat)", "description": "",
"main": "index.js",
"scripts": { "scripts": {
"build:shared": "npm run build --workspace=@stockhome/shared",
"build:backend": "npm run build --workspace=backend",
"build:frontend": "npm run build --workspace=frontend",
"dev:frontend": "npm run dev --workspace=frontend", "dev:frontend": "npm run dev --workspace=frontend",
"dev:backend": "npm run dev --workspace=backend", "dev:backend": "npm run build:shared && npm run dev --workspace=backend",
"dev:docker": "docker compose -f docker-compose.dev.yml up -d --wait", "dev:docker": "docker compose -f docker-compose.dev.yml up -d --wait",
"dev": "npm run dev:docker && concurrently --kill-others \"npm:dev:frontend\" \"npm:dev:backend\"", "dev": "npm run dev:docker && concurrently --kill-others \"npm:dev:frontend\" \"npm:dev:backend\"",
"dev:stop": "docker compose -f docker-compose.dev.yml down" "dev:stop": "docker compose -f docker-compose.dev.yml down",
"buildProd": "npm run build:shared && npm run build:backend && npm run build:frontend"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -22,8 +26,16 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "module",
"overrides": {
"@babel/core": "^7.26.0"
},
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.3" "@eslint/js": "^10.0.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"concurrently": "^10.0.3",
"globals": "^17.7.0",
"prettier": "^3.9.6"
} }
} }
+948
View File
@@ -0,0 +1,948 @@
{
"name": "shared",
"version": "0.0.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shared",
"version": "0.0.0-dev",
"devDependencies": {
"barrelsby": "^2.8.1",
"vite-plugin-dts": "^5.0.3"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
"integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/yargs-parser": "*"
}
},
"node_modules/@types/yargs-parser": {
"version": "21.0.3",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.28"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/barrelsby": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/barrelsby/-/barrelsby-2.8.1.tgz",
"integrity": "sha512-barN2MVKqUVwmjRy3JLSMYufrBDcdWUc2pjlR0V9P8S3aMvvJ4StFz1GJMzEi5GBoQlnBIWOcCxBDzI2xfaaGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/yargs": "^17.0.10",
"signale": "^1.4.0",
"yargs": "^17.4.1"
},
"bin": {
"barrelsby": "bin/cli.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true,
"license": "MIT"
},
"node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
"integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
"dev": true,
"license": "MIT"
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"dev": true,
"license": "MIT"
},
"node_modules/figures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
"integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.5"
},
"engines": {
"node": ">=4"
}
},
"node_modules/find-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
"integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^2.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/json-parse-better-errors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
"integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
"dev": true,
"license": "MIT"
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/load-json-file": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
"integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.2",
"parse-json": "^4.0.0",
"pify": "^3.0.0",
"strip-bom": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/locate-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
"integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^2.0.0",
"path-exists": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/p-limit": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
"integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/p-locate": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
"integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/p-try": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
"integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"error-ex": "^1.3.1",
"json-parse-better-errors": "^1.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
"integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/pkg-conf": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz",
"integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"find-up": "^2.0.0",
"load-json-file": "^4.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/signale": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz",
"integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^2.3.2",
"figures": "^2.0.0",
"pkg-conf": "^2.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unplugin-dts": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.0.3.tgz",
"integrity": "sha512-/GR887wfG4r1cWyt1UZsLRuMIjsmEbGkS9yJrz+0dsToHAYUD5CTyP3JMGVLv25j9K0mJcwAVvZno/aTuSUvNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.4",
"@volar/typescript": "^2.4.26",
"compare-versions": "^6.1.1",
"debug": "^4.4.0",
"kolorist": "^1.8.0",
"local-pkg": "^1.1.1",
"magic-string": "^0.30.17",
"unplugin": "^2.3.2"
},
"peerDependencies": {
"@microsoft/api-extractor": ">=7",
"@rspack/core": "^1",
"@vue/language-core": "^3.1.5",
"esbuild": "*",
"rolldown": "*",
"rollup": ">=3",
"typescript": ">=4",
"vite": ">=3",
"webpack": "^4 || ^5"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"@rspack/core": {
"optional": true
},
"@vue/language-core": {
"optional": true
},
"esbuild": {
"optional": true
},
"rolldown": {
"optional": true
},
"rollup": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/vite-plugin-dts": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-5.0.3.tgz",
"integrity": "sha512-gIth6NdCEHWPiiRMCK3N6C8WjvdsrtEQrmsiG8h6Ov+lFP+b07Y+wcs9H0H7n146l0PDTYK4cQN1vgeG1pMdRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"unplugin-dts": "1.0.3"
},
"peerDependencies": {
"@microsoft/api-extractor": ">=7",
"rollup": ">=3",
"vite": ">=3"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"rollup": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/wrap-ansi/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@stockhome/shared",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"prebuild": "barrelsby --directory src --delete --single",
"build:esm": "tsc -p tsconfig.esm.json && node scripts/postbuild-esm.cjs",
"build:cjs": "tsc -p tsconfig.cjs.json && find dist/cjs -name \"*.js\" -type f -exec sh -c 'mv \"$1\" \"${1%.js}.cjs\"' _ {} \\; && node scripts/postbuild.cjs",
"build": "rm -rf dist && npm run prebuild && npm run build:esm && npm run build:cjs",
"lint": "eslint ."
},
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"types": "./dist/types/index.d.ts",
"devDependencies": {
"barrelsby": "^2.8.1",
"typescript": "~6.0.2"
}
}
+30
View File
@@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
// Recursively process all .js files in esm directory
function processDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
processDirectory(filePath);
} else if (file.endsWith('.js')) {
// Read the file
let content = fs.readFileSync(filePath, 'utf8');
// Replace import/export statements to add .js extension
// Replace from "./path" with from "./path.js" (for relative imports only)
content = content.replace(/from ["'](\.[^"']*?)(?<!\.js)["']/g, 'from "$1.js"');
// Write back
fs.writeFileSync(filePath, content, 'utf8');
console.log(`✓ Updated ${filePath}`);
}
});
}
processDirectory('./dist/esm');
console.log('✓ ESM post-build processing complete');
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
// Recursively process all .cjs files
function processDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
processDirectory(filePath);
} else if (file.endsWith('.cjs')) {
// Read the file
let content = fs.readFileSync(filePath, 'utf8');
// Replace require statements to add .cjs extension
content = content.replace(/require\("([^"]*?)(?<!\.cjs)"\)/g, 'require("$1.cjs")');
// Write back
fs.writeFileSync(filePath, content, 'utf8');
console.log(`✓ Updated ${filePath}`);
}
});
}
processDirectory('./dist/cjs');
console.log('✓ Post-build processing complete');
+42
View File
@@ -0,0 +1,42 @@
export const GENERAL_ERROR_CODE = {
UNEXPECTED_SERVER_ERROR: ["EG001", "Unexpected server error"],
} as const;
export const USER_ERROR_CODE = {
WRONG_USERNAME_PASSWORD: ["EU001", "Username or password is wrong"],
USER_IS_DEACTIVATED: ["EU002", "User is deactivated"],
INVALID_REQUEST_BODY: [
"EU003",
"Invalid request body. Username and password are required.",
],
INVALID_PARAMETERS: ["EU004", "Invalid or missing parameters."],
TOKEN_GENERATION_FAILED: ["EU005", "Failed to generate user token."],
LOGIN_FAILED: ["EU006", "Failed to log in user."],
PASSWORD_CHANGE_FAILED: ["EU007", "Failed to change password."],
SETTINGS_NOT_UPDATED: ["EU008", "Settings could not be updated."],
SETTINGS_NOT_FOUND: ["EU009", "Settings could not be retrieved."],
PASSWORDS_NOT_MATCHED: ["EU010", "The two passwords are not matching."],
} as const;
export const STORAGE_ERROR_CODE = {
INVALID_REQUEST_BODY: ["ES001", "Invalid request body"],
INVALID_PARAMETERS: [
"ES002",
"Parameter storageUUID or/and parameter values are/is missing.",
],
STORAGE_NOT_UPDATED: ["ES003", "Storage is not updated."],
STORAGE_NOT_DELETED: ["ES004", "Storage is not deleted."],
NO_STORAGE_LOCATIONS_FOUND: ["ES005", "No storage locations found"],
STORAGE_NOT_CREATED: ["ES006", "Storage is not created."],
} as const;
export const PRODUCT_ERROR_CODE = {
PRODUCT_NOT_CREATED: ["EP001", "Error while creating product"],
NO_PRODUCTS_FOUND: ["EP002", "No products found"],
PRODUCT_NOT_FOUND: ["EP003", "Product not found"],
PRODUCT_AMOUNT_NOT_UPDATED: ["EP004", "Error while updating product amount"],
PRODUCT_NOT_UPDATED: ["EP005", "Error while updating product"],
PRODUCT_NOT_DELETED: ["EP006", "Error while deleting product"],
INVALID_REQUEST_BODY: ["EP007", "Invalid request body. Name is required."],
INVALID_PARAMETERS: ["EP008", "Invalid or missing parameters."],
} as const;
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"rootDir": "./src",
"target": "ES2022",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/cjs",
"module": "CommonJS",
"moduleResolution": "Node",
"declaration": false,
"declarationMap": false,
"ignoreDeprecations": "6.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist/esm",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"declarationDir": "./dist/types",
"declarationMap": true
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/refs",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"skipLibCheck": true,
"ignoreDeprecations": "6.0"
},
"include": ["src/**/*.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./shared"
},
{
"path": "./backend"
}
]
}