Compare commits
31
Commits
84fa2e0ab0
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be2e250ba1 | ||
|
|
6f3759d48d | ||
|
|
80fb79dedd | ||
|
|
d6603577a2 | ||
|
|
ef79a07a55 | ||
|
|
05ea471351 | ||
|
|
9df8570e5a | ||
|
|
b5a4f39c6f | ||
|
|
262c2b665d | ||
|
|
bde647aa08 | ||
|
|
e2a7d2e560 | ||
|
|
99246e3e3d | ||
|
|
3153076d72 | ||
|
|
ef5fb3e1da | ||
|
|
aa87442557 | ||
|
|
580db6afe9 | ||
|
|
bbca5a36cc | ||
|
|
0d4a048d16 | ||
|
|
57fe6dc9ac | ||
|
|
ef905fb30b | ||
|
|
925cb73c47 | ||
|
|
99a1097053 | ||
|
|
73dd672fdb | ||
|
|
92b5fd92bc | ||
|
|
d486655f54 | ||
|
|
018a7aefc7 | ||
|
|
6be34c93aa | ||
|
|
9c50c3f300 | ||
|
|
3a5c16031a | ||
|
|
c6bba8a40c | ||
|
|
87bed4e1c7 |
Binary file not shown.
|
Before Width: | Height: | Size: 536 KiB After Width: | Height: | Size: 912 KiB |
@@ -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
|
||||||
._*
|
._*
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
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
@@ -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
@@ -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);
|
||||||
Generated
-1096
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,14 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "server.ts",
|
"main": "server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"dev": "tsx --watch server.ts",
|
||||||
"start": "node server.ts",
|
"build": "tsc -b && node scripts/copy-assets.cjs",
|
||||||
"dev": "node --watch server.ts"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stockhome/shared": "1.0.0",
|
"@stockhome/shared": "1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^26.1.0"
|
"@types/node": "^26.1.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"tsx": "^4.23.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import mysql, {type ResultSetHeader, type RowDataPacket} from "mysql2/promise";
|
import mysql, {
|
||||||
|
type ResultSetHeader,
|
||||||
|
type RowDataPacket,
|
||||||
|
} from "mysql2/promise";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {returnErrorCode} from "../../../services/helperFuncs.js";
|
import { returnErrorCode } from "../../../services/helperFuncs.js";
|
||||||
import {PRODUCT_ERROR_CODE} from "@stockhome/shared";
|
import { PRODUCT_ERROR_CODE } from "@stockhome/shared";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -20,12 +23,22 @@ export const newProduct = async (
|
|||||||
storage_location: string,
|
storage_location: string,
|
||||||
expiry_date: string,
|
expiry_date: string,
|
||||||
bottling_date: string,
|
bottling_date: string,
|
||||||
): Promise<boolean | { success: boolean; code: string; data: null; message: string }> => {
|
): Promise<
|
||||||
|
boolean | { success: boolean; code: string; data: null; message: string }
|
||||||
|
> => {
|
||||||
const newPrice = price !== "" ? price : null;
|
const newPrice = price !== "" ? price : null;
|
||||||
|
|
||||||
const [result] = await pool.query<ResultSetHeader>(
|
const [result] = await pool.query<ResultSetHeader>(
|
||||||
"INSERT INTO products (name, description, price, amount, storage_location, expiry_date, bottling_date) VALUES (?, ?, ?, ?, UUID_TO_BIN(?), ?, ?)",
|
"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],
|
[
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
newPrice,
|
||||||
|
amount,
|
||||||
|
storage_location,
|
||||||
|
expiry_date,
|
||||||
|
bottling_date,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.affectedRows > 0) {
|
if (result.affectedRows > 0) {
|
||||||
@@ -182,7 +195,6 @@ export const deleteProduct = async (uuid: string) => {
|
|||||||
data: null,
|
data: null,
|
||||||
message: "Product deleted successfully.",
|
message: "Product deleted successfully.",
|
||||||
};
|
};
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_NOT_DELETED);
|
return returnErrorCode(PRODUCT_ERROR_CODE.PRODUCT_NOT_DELETED);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import mysql, {type ResultSetHeader, type RowDataPacket} from "mysql2/promise";
|
import mysql, {
|
||||||
|
type ResultSetHeader,
|
||||||
|
type RowDataPacket,
|
||||||
|
} from "mysql2/promise";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {STORAGE_ERROR_CODE} from "@stockhome/shared";
|
import { STORAGE_ERROR_CODE } from "@stockhome/shared";
|
||||||
import {returnErrorCode} from "../../../services/helperFuncs.js";
|
import { returnErrorCode } from "../../../services/helperFuncs.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import mysql, {type ResultSetHeader, type RowDataPacket} from "mysql2/promise";
|
import mysql, {
|
||||||
|
type ResultSetHeader,
|
||||||
|
type RowDataPacket,
|
||||||
|
} from "mysql2/promise";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {GENERAL_ERROR_CODE, USER_ERROR_CODE} from "@stockhome/shared";
|
import { GENERAL_ERROR_CODE, USER_ERROR_CODE } from "@stockhome/shared";
|
||||||
import {returnErrorCode} from "../../../services/helperFuncs.js";
|
import { returnErrorCode } from "../../../services/helperFuncs.js";
|
||||||
import type { AuthTokenPayload } from "../../../services/tokenService";
|
import type { AuthTokenPayload } from "../../../services/tokenService";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -127,6 +130,6 @@ export const changePassword = async (
|
|||||||
message: "Successfully fetched settings.",
|
message: "Successfully fetched settings.",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return returnErrorCode(GENERAL_ERROR_CODE.UNEXPECTED_SERVER_ERROR);
|
return returnErrorCode(USER_ERROR_CODE.PASSWORD_CHANGE_FAILED);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {authenticate} from "../../services/tokenService.ts";
|
import { authenticate } from "../../services/tokenService";
|
||||||
import {
|
import {
|
||||||
allProducts,
|
allProducts,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
productDetails,
|
productDetails,
|
||||||
setAmount,
|
setAmount,
|
||||||
updateItem,
|
updateItem,
|
||||||
} from "./database/products.database.ts";
|
} from "./database/products.database";
|
||||||
import {GENERAL_ERROR_CODE, PRODUCT_ERROR_CODE} from "@stockhome/shared";
|
import { GENERAL_ERROR_CODE, PRODUCT_ERROR_CODE } from "@stockhome/shared";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {authenticate} from "../../services/tokenService.js";
|
import { authenticate } from "../../services/tokenService";
|
||||||
import {allStorages, deleteStorage, newStorage, updateStorage,} from "./database/storage.database.ts";
|
import {
|
||||||
import {GENERAL_ERROR_CODE, STORAGE_ERROR_CODE} from "@stockhome/shared";
|
allStorages,
|
||||||
|
deleteStorage,
|
||||||
|
newStorage,
|
||||||
|
updateStorage,
|
||||||
|
} from "./database/storage.database";
|
||||||
|
import { GENERAL_ERROR_CODE, STORAGE_ERROR_CODE } from "@stockhome/shared";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -46,7 +51,7 @@ router.get("/all-storages", authenticate, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("/new-storage", authenticate, async (req, res) => {
|
router.post("/new-storage", authenticate, async (req, res) => {
|
||||||
const {name, description} = req.body;
|
const { name, description } = req.body;
|
||||||
|
|
||||||
let desc = description;
|
let desc = description;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import {authenticate, generateToken} from "../../services/tokenService.js";
|
import { authenticate, generateToken } from "../../services/tokenService";
|
||||||
import {changePassword, findUser, getSettings, loginUser, updateSettings,} from "./database/users.database.ts";
|
import {
|
||||||
import {GENERAL_ERROR_CODE, USER_ERROR_CODE} from "@stockhome/shared";
|
changePassword,
|
||||||
|
findUser,
|
||||||
|
getSettings,
|
||||||
|
loginUser,
|
||||||
|
updateSettings,
|
||||||
|
} from "./database/users.database";
|
||||||
|
import { GENERAL_ERROR_CODE, USER_ERROR_CODE } from "@stockhome/shared";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
+14
-10
@@ -1,13 +1,13 @@
|
|||||||
import type {NextFunction, Request, Response} from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import mysql, {type ResultSetHeader, type RowDataPacket} from "mysql2";
|
import mysql, { type ResultSetHeader, type RowDataPacket } from "mysql2";
|
||||||
import {readFile} from "fs/promises";
|
import { readFile } from "fs/promises"; // frontend routes
|
||||||
// frontend routes
|
import userRouter from "./routes/app/users.route";
|
||||||
import userRouter from "./routes/app/users.route.ts";
|
import productRouter from "./routes/app/products.route";
|
||||||
import productRouter from "./routes/app/products.route.ts";
|
import storageRouter from "./routes/app/storage.route";
|
||||||
import storageRouter from "./routes/app/storage.route.ts";
|
import path from "node:path";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -17,7 +17,7 @@ const port = 8004;
|
|||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({extended: true}));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
const pool = mysql
|
const pool = mysql
|
||||||
.createPool({
|
.createPool({
|
||||||
@@ -49,13 +49,17 @@ const runStartup = async (port: number) => {
|
|||||||
);
|
);
|
||||||
firstStartupValue = firstResponse[0]?.value ?? null;
|
firstStartupValue = firstResponse[0]?.value ?? null;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && (err as NodeJS.ErrnoException & { code?: string }).code !== "ER_NO_SUCH_TABLE") {
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err as NodeJS.ErrnoException & { code?: string }).code !==
|
||||||
|
"ER_NO_SUCH_TABLE"
|
||||||
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstStartupValue !== "false") {
|
if (firstStartupValue !== "false") {
|
||||||
const schemaPath = new URL("./database.scheme.sql", import.meta.url);
|
const schemaPath = path.join(__dirname, "database.scheme.sql");
|
||||||
const schemaSql = await readFile(schemaPath, "utf8");
|
const schemaSql = await readFile(schemaPath, "utf8");
|
||||||
const statements = schemaSql
|
const statements = schemaSql
|
||||||
.split(";")
|
.split(";")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type {ErrorCode} from "../misc/types.ts";
|
import type { ErrorCode } from "../misc/types.ts";
|
||||||
|
|
||||||
export const returnErrorCode = (errorKey: ErrorCode) => {
|
export const returnErrorCode = (errorKey: ErrorCode) => {
|
||||||
if (!Array.isArray(errorKey) || errorKey.length < 2) {
|
if (!Array.isArray(errorKey) || errorKey.length < 2) {
|
||||||
@@ -9,6 +9,6 @@ export const returnErrorCode = (errorKey: ErrorCode) => {
|
|||||||
success: false,
|
success: false,
|
||||||
code: errorKey[0],
|
code: errorKey[0],
|
||||||
data: null,
|
data: null,
|
||||||
message: errorKey[1]
|
message: errorKey[1],
|
||||||
}
|
};
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {NextFunction, Request, Response} from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import {type JWTPayload, jwtVerify, SignJWT} from "jose";
|
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
|
||||||
import env from "dotenv";
|
import env from "dotenv";
|
||||||
|
|
||||||
env.config();
|
env.config();
|
||||||
@@ -17,19 +17,23 @@ declare module "express-serve-static-core" {
|
|||||||
|
|
||||||
export async function generateToken(payload: AuthTokenPayload) {
|
export async function generateToken(payload: AuthTokenPayload) {
|
||||||
return await new SignJWT(payload)
|
return await new SignJWT(payload)
|
||||||
.setProtectedHeader({alg: "HS256"})
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime("24h")
|
.setExpirationTime("24h")
|
||||||
.sign(secret);
|
.sign(secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticate(req: Request, res: Response, next: NextFunction) {
|
export async function authenticate(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers["authorization"];
|
||||||
const token = authHeader && authHeader.split(" ")[1];
|
const token = authHeader && authHeader.split(" ")[1];
|
||||||
|
|
||||||
if (token == null) return res.sendStatus(401);
|
if (token == null) return res.sendStatus(401);
|
||||||
|
|
||||||
const {payload} = await jwtVerify<AuthTokenPayload>(token, secret);
|
const { payload } = await jwtVerify<AuthTokenPayload>(token, secret);
|
||||||
req.user = payload;
|
req.user = payload;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
+22
-35
@@ -1,45 +1,32 @@
|
|||||||
{
|
{
|
||||||
// Visit https://aka.ms/tsconfig to read more about this file
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// File Layout
|
"composite": true,
|
||||||
// "rootDir": "./src",
|
"rootDir": ".",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
// Environment Settings
|
"module": "CommonJS",
|
||||||
// See also https://aka.ms/tsconfig/module
|
"target": "ES2022",
|
||||||
"module": "esnext",
|
"moduleResolution": "Node",
|
||||||
"target": "esnext",
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "bundler",
|
"allowSyntheticDefaultImports": true,
|
||||||
// For nodejs:
|
"ignoreDeprecations": "6.0",
|
||||||
// "lib": ["esnext"],
|
"types": ["node"],
|
||||||
"types": [
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
// and npm install -D @types/node
|
|
||||||
|
|
||||||
// Other Outputs
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
// Stricter Typechecking Options
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"exactOptionalPropertyTypes": true,
|
|
||||||
// Style Options
|
|
||||||
// "noImplicitReturns": true,
|
|
||||||
// "noImplicitOverride": true,
|
|
||||||
// "noUnusedLocals": true,
|
|
||||||
// "noUnusedParameters": true,
|
|
||||||
// "noFallthroughCasesInSwitch": true,
|
|
||||||
// "noPropertyAccessFromIndexSignature": true,
|
|
||||||
|
|
||||||
// Recommended Options
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["./**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/vite.config.ts",
|
||||||
|
"**/vite.config.*",
|
||||||
|
"**/*.config.ts",
|
||||||
|
"**/*.config.*"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../shared"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-18
@@ -1,32 +1,29 @@
|
|||||||
import {defineConfig} from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import path from 'path'
|
import path from "path";
|
||||||
import {dirname} from "node:path";
|
import { dirname } from "node:path";
|
||||||
import {fileURLToPath} from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: 'src',
|
root: "src",
|
||||||
build: {
|
build: {
|
||||||
outDir: '../dist',
|
outDir: "../dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
minify: 'esbuild', // Use esbuild for minification (default)
|
minify: "esbuild",
|
||||||
assetsDir: 'assets',
|
assetsDir: "assets",
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: path.resolve(__dirname, 'src/index.html'),
|
input: path.resolve(__dirname, "src/index.html"),
|
||||||
output: {
|
output: {
|
||||||
assetFileNames: 'assets/[name]-[hash][extname]' // Hashing for cache busting
|
assetFileNames: "assets/[name]-[hash][extname]",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'), // Optional alias for cleaner imports
|
"@": path.resolve(__dirname, "src"),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
define: {
|
},
|
||||||
'process.env.NODE_ENV': '"production"' // Inject environment variables
|
});
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
+6
-4
@@ -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:
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
+12
-4
@@ -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;"]
|
||||||
Generated
-5116
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")} · {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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />;
|
|
||||||
}
|
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: "
|
||||||
}
|
}
|
||||||
@@ -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: "
|
||||||
}
|
}
|
||||||
@@ -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) };
|
||||||
|
};
|
||||||
@@ -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
-10
@@ -1,13 +1,26 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"target": "ES2022",
|
||||||
"target": "esnext",
|
"useDefineForClassFields": true,
|
||||||
"esModuleInterop": true,
|
"lib": [
|
||||||
"moduleResolution": "node"
|
"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,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()] }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+860
-893
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -7,15 +7,17 @@
|
|||||||
"shared"
|
"shared"
|
||||||
],
|
],
|
||||||
"version": "0.1.0-dev",
|
"version": "0.1.0-dev",
|
||||||
"description": "       [](#)     ",
|
"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": ""
|
"buildProd": "npm run build:shared && npm run build:backend && npm run build:frontend"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -24,11 +26,16 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "module",
|
||||||
|
"overrides": {
|
||||||
|
"@babel/core": "^7.26.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"concurrently": "^10.0.3"
|
"concurrently": "^10.0.3",
|
||||||
|
"globals": "^17.7.0",
|
||||||
|
"prettier": "^3.9.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -5,13 +5,21 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "barrelsby --directory src --delete --single",
|
"prebuild": "barrelsby --directory src --delete --single",
|
||||||
"build": "tsc",
|
"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 ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"main": "./dist/src/index.js",
|
"exports": {
|
||||||
"types": "./dist/src/index.d.ts",
|
".": {
|
||||||
|
"types": "./dist/types/index.d.ts",
|
||||||
|
"import": "./dist/esm/index.js",
|
||||||
|
"require": "./dist/cjs/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": "./dist/types/index.d.ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"barrelsby": "^2.8.1",
|
"barrelsby": "^2.8.1",
|
||||||
"vite-plugin-dts": "^5.0.3"
|
"typescript": "~6.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -5,18 +5,25 @@ export const GENERAL_ERROR_CODE = {
|
|||||||
export const USER_ERROR_CODE = {
|
export const USER_ERROR_CODE = {
|
||||||
WRONG_USERNAME_PASSWORD: ["EU001", "Username or password is wrong"],
|
WRONG_USERNAME_PASSWORD: ["EU001", "Username or password is wrong"],
|
||||||
USER_IS_DEACTIVATED: ["EU002", "User is deactivated"],
|
USER_IS_DEACTIVATED: ["EU002", "User is deactivated"],
|
||||||
INVALID_REQUEST_BODY: ["EU003", "Invalid request body. Username and password are required."],
|
INVALID_REQUEST_BODY: [
|
||||||
|
"EU003",
|
||||||
|
"Invalid request body. Username and password are required.",
|
||||||
|
],
|
||||||
INVALID_PARAMETERS: ["EU004", "Invalid or missing parameters."],
|
INVALID_PARAMETERS: ["EU004", "Invalid or missing parameters."],
|
||||||
TOKEN_GENERATION_FAILED: ["EU005", "Failed to generate user token."],
|
TOKEN_GENERATION_FAILED: ["EU005", "Failed to generate user token."],
|
||||||
LOGIN_FAILED: ["EU006", "Failed to log in user."],
|
LOGIN_FAILED: ["EU006", "Failed to log in user."],
|
||||||
PASSWORD_CHANGE_FAILED: ["EU007", "Failed to change password."],
|
PASSWORD_CHANGE_FAILED: ["EU007", "Failed to change password."],
|
||||||
SETTINGS_NOT_UPDATED: ["EU008", "Settings could not be updated."],
|
SETTINGS_NOT_UPDATED: ["EU008", "Settings could not be updated."],
|
||||||
SETTINGS_NOT_FOUND: ["EU009", "Settings could not be retrieved."],
|
SETTINGS_NOT_FOUND: ["EU009", "Settings could not be retrieved."],
|
||||||
|
PASSWORDS_NOT_MATCHED: ["EU010", "The two passwords are not matching."],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const STORAGE_ERROR_CODE = {
|
export const STORAGE_ERROR_CODE = {
|
||||||
INVALID_REQUEST_BODY: ["ES001", "Invalid request body"],
|
INVALID_REQUEST_BODY: ["ES001", "Invalid request body"],
|
||||||
INVALID_PARAMETERS: ["ES002", "Parameter storageUUID or/and parameter values are/is missing."],
|
INVALID_PARAMETERS: [
|
||||||
|
"ES002",
|
||||||
|
"Parameter storageUUID or/and parameter values are/is missing.",
|
||||||
|
],
|
||||||
STORAGE_NOT_UPDATED: ["ES003", "Storage is not updated."],
|
STORAGE_NOT_UPDATED: ["ES003", "Storage is not updated."],
|
||||||
STORAGE_NOT_DELETED: ["ES004", "Storage is not deleted."],
|
STORAGE_NOT_DELETED: ["ES004", "Storage is not deleted."],
|
||||||
NO_STORAGE_LOCATIONS_FOUND: ["ES005", "No storage locations found"],
|
NO_STORAGE_LOCATIONS_FOUND: ["ES005", "No storage locations found"],
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"target": "ES2022",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/cjs",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-39
@@ -1,46 +1,16 @@
|
|||||||
{
|
{
|
||||||
// Visit https://aka.ms/tsconfig to read more about this file
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// File Layout
|
"composite": true,
|
||||||
// "rootDir": "./src",
|
|
||||||
"outDir": "./dist",
|
|
||||||
|
|
||||||
// Environment Settings
|
|
||||||
// See also https://aka.ms/tsconfig/module
|
|
||||||
"module": "esnext",
|
|
||||||
"target": "esnext",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"types": [],
|
|
||||||
// For nodejs:
|
|
||||||
// "lib": ["esnext"],
|
|
||||||
// "types": ["node"],
|
|
||||||
// and npm install -D @types/node
|
|
||||||
|
|
||||||
// Other Outputs
|
|
||||||
"sourceMap": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
// Stricter Typechecking Options
|
"outDir": "./dist/refs",
|
||||||
"noUncheckedIndexedAccess": true,
|
"module": "CommonJS",
|
||||||
"exactOptionalPropertyTypes": true,
|
"moduleResolution": "Node",
|
||||||
|
|
||||||
// Style Options
|
|
||||||
// "noImplicitReturns": true,
|
|
||||||
// "noImplicitOverride": true,
|
|
||||||
// "noUnusedLocals": true,
|
|
||||||
// "noUnusedParameters": true,
|
|
||||||
// "noFallthroughCasesInSwitch": true,
|
|
||||||
// "noPropertyAccessFromIndexSignature": true,
|
|
||||||
|
|
||||||
// Recommended Options
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
}
|
"ignoreDeprecations": "6.0"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import dts from "vite-plugin-dts";
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
dts(),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./shared"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user