diff --git a/Dockerfile b/Dockerfile index d6f46a3..7878be9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,9 @@ FROM node:16-alpine as client-builder COPY ./client /client WORKDIR /client -RUN npm install +RUN npm ci -RUN echo "VITE_APIURL=/api/v1" > .env.production +RUN rm -f .env && echo "VITE_APIURL=/api/v1" > .env.production RUN npm run build FROM python:3.11-alpine as python-builder diff --git a/client/package-lock.json b/client/package-lock.json index d6af05b..83c8f79 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -17,6 +17,7 @@ "@refinedev/simple-rest": "^4.5.0", "@tanstack/react-query": "^4.29.19", "@types/loadable__component": "^5.13.4", + "@yudiel/react-qr-scanner": "^1.1.10", "antd": "^5.6.4", "flag-icons": "^6.7.0", "i18next": "^23.2.8", @@ -26,6 +27,7 @@ "react-dom": "^18.2.0", "react-i18next": "^13.0.1", "react-router-dom": "^6.14.1", + "react-to-print": "^2.14.13", "vite-plugin-svgr": "^3.2.0" }, "devDependencies": { @@ -40,7 +42,8 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.1", "typescript": "^5.1.6", - "vite": "^4.4.1" + "vite": "^4.4.1", + "vite-plugin-mkcert": "^1.16.0" }, "engines": { "node": "16.x" @@ -1553,6 +1556,195 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.4.tgz", + "integrity": "sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/core": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.4.tgz", + "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", + "dev": true, + "dependencies": { + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/endpoint": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", + "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", + "dev": true, + "dependencies": { + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/endpoint/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.6.tgz", + "integrity": "sha512-Fxyxdy/JH0MnIB5h+UQ3yCoh1FG4kWXfFKkpWqjZHw/p+Kc8Y44Hu/kCgNBT6nU1shNumEchmW/sUO1JuQnPcw==", + "dev": true, + "dependencies": { + "@octokit/request": "^6.0.0", + "@octokit/types": "^9.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.0.0.tgz", + "integrity": "sha512-V8GImKs3TeQRxRtXFpG2wl19V7444NIOTDF24AWuIbmNaNYOQMWRbjcGDXV5B+0n887fgDcuMNOmlul+k+oJtw==", + "dev": true + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-6.1.2.tgz", + "integrity": "sha512-qhrmtQeHU/IivxucOV1bbI/xZyC/iOBhclokv7Sut5vnejAIAEXVcGQeRpQlU39E0WwK9lNvJHphHri/DB6lbQ==", + "dev": true, + "dependencies": { + "@octokit/tsconfig": "^1.0.2", + "@octokit/types": "^9.2.3" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=4" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "dev": true, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-7.2.3.tgz", + "integrity": "sha512-I5Gml6kTAkzVlN7KCtjOM+Ruwe/rQppp0QU372K1GP7kNOYEKe8Xn5BW4sE62JAHdwpq95OQK/qGNyKQMUzVgA==", + "dev": true, + "dependencies": { + "@octokit/types": "^10.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-10.0.0.tgz", + "integrity": "sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^18.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", + "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", + "dev": true, + "dependencies": { + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^9.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/request-error": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", + "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", + "dev": true, + "dependencies": { + "@octokit/types": "^9.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/request/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@octokit/rest": { + "version": "19.0.13", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.13.tgz", + "integrity": "sha512-/EzVox5V9gYGdbAI+ovYj3nXQT1TtTHRT+0eZPcuC05UFSWO3mdO9UY1C0i2eLF9Un1ONJkAk+IEtYGAC+TahA==", + "dev": true, + "dependencies": { + "@octokit/core": "^4.2.1", + "@octokit/plugin-paginate-rest": "^6.1.2", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^7.1.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/tsconfig": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@octokit/tsconfig/-/tsconfig-1.0.2.tgz", + "integrity": "sha512-I0vDR0rdtP8p2lGMzvsJzbhdOWy405HcGovrspJ8RRibHnyRgggUSNO5AIox5LmqiwmatHKYsvj6VGFHkqS7lA==", + "dev": true + }, + "node_modules/@octokit/types": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", + "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^18.0.0" + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", @@ -2538,6 +2730,38 @@ "vite": "^4.2.0" } }, + "node_modules/@yudiel/react-qr-scanner": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@yudiel/react-qr-scanner/-/react-qr-scanner-1.1.10.tgz", + "integrity": "sha512-+YIHbnocynkkGgyMK8MMkn1NBnJRwJntdm4shQ+linm4NdGdSAavLYxJYwnk2PixZyVu/GZ3Uc2aM0XWlBq+Zw==", + "dependencies": { + "@zxing/library": "^0.20.0" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.20.0.tgz", + "integrity": "sha512-6Ev6rcqVjMakZFIDvbUf0dtpPGeZMTfyxYg4HkVWioWeN7cRcnUWT3bU6sdohc82O1nPXcjq6WiGfXX2Pnit6A==", + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "optional": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2830,6 +3054,12 @@ "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "node_modules/atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -2945,6 +3175,12 @@ } ] }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3522,6 +3758,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", @@ -3790,6 +4038,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3799,6 +4056,12 @@ "node": ">= 0.8" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -4926,6 +5189,20 @@ "node": ">=0.10.0" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7640,6 +7917,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -8487,6 +8770,15 @@ "react-dom": "^16.3.0 || ^17.0.0" } }, + "node_modules/react-to-print": { + "version": "2.14.13", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-2.14.13.tgz", + "integrity": "sha512-PqUGgTRZvkyBzWgaZHVBECWPX2nGEc3HOUy6WNXof6HT4yTFI92wtIkqQtr4jfvHbadqjwTgzgh6vgN8KXlWWw==", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-virtual": { "version": "2.10.4", "resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz", @@ -9781,6 +10073,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", @@ -9994,6 +10294,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-user-agent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==", + "dev": true + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -10245,6 +10551,35 @@ } } }, + "node_modules/vite-plugin-mkcert": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-1.16.0.tgz", + "integrity": "sha512-5r+g8SB9wZzLNUFekGwZo3e0P6QlS6rbxK5p9z/itxNAimsYohgjK/YfVPVxM9EuglP9hjridq0lUejo9v1nVg==", + "dev": true, + "dependencies": { + "@octokit/rest": "^19.0.5", + "axios": "^1.2.2", + "debug": "^4.3.4", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=v16.7.0" + }, + "peerDependencies": { + "vite": ">=3" + } + }, + "node_modules/vite-plugin-mkcert/node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/vite-plugin-svgr": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-3.2.0.tgz", diff --git a/client/package.json b/client/package.json index 8ffdbb0..38995e1 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "@refinedev/simple-rest": "^4.5.0", "@tanstack/react-query": "^4.29.19", "@types/loadable__component": "^5.13.4", + "@yudiel/react-qr-scanner": "^1.1.10", "antd": "^5.6.4", "flag-icons": "^6.7.0", "i18next": "^23.2.8", @@ -25,6 +26,7 @@ "react-dom": "^18.2.0", "react-i18next": "^13.0.1", "react-router-dom": "^6.14.1", + "react-to-print": "^2.14.13", "vite-plugin-svgr": "^3.2.0" }, "devDependencies": { @@ -39,7 +41,8 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.1", "typescript": "^5.1.6", - "vite": "^4.4.1" + "vite": "^4.4.1", + "vite-plugin-mkcert": "^1.16.0" }, "scripts": { "dev": "refine dev", diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 549f7ea..0bceaad 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -53,6 +53,58 @@ "dashboard": { "title": "Dashboard" }, + "printing": { + "generic": { + "title": "Printing", + "description": "Tune in the settings below to get the desired print layout. Keep in mind that printers and your OS may apply it's own margins and scaling, so you may need to adjust the settings accordingly. Test it on a piece of regular paper before printing on the actual labels.", + "print": "Print", + "columns": "Columns", + "rows": "Rows", + "paperSize": "Paper Size", + "showBorder": "Show Border", + "previewScale": "Preview Scale", + "marginLeft": "Left Margin", + "marginRight": "Right Margin", + "marginTop": "Top Margin", + "marginBottom": "Bottom Margin", + "blockRowsTop": "Block Rows Top", + "blockRowsBottom": "Block Rows Bottom", + "blockColumnsLeft": "Block Columns Left", + "blockColumnsRight": "Block Columns Right", + "borders": { + "none": "None", + "border": "Border", + "grid": "Grid" + } + }, + "qrcode": { + "button": "Print QR Codes", + "title": "QR Code Printing", + "textSize": "Content Text Size", + "showSpoolmanIcon": "Show Spoolman Icon", + "showContent": "Show QR Code Content" + }, + "spoolSelect": { + "title": "Select Spools", + "description": "Select spools to print QR codes for.", + "showArchived": "Show Archived", + "noSpoolsSelected": "You have not selected any spools.", + "selectAll": "Select/Unselect All", + "selectedTotal_one": "{{count}} spool selected", + "selectedTotal_other": "{{count}} spools selected" + } + }, + "scanner": { + "title": "QR Code Scanner", + "description": "Scan a Spoolman QR code to view details about the spool.", + "error": { + "notAllowed": "You have not allowed access to the camera.", + "insecureContext": "The page is not served over HTTPS.", + "streamApiNotSupported": "The browser does not support the MediaStream API.", + "notReadable": "The camera is not readable.", + "unknown": "An unknown error occurred. ({{error}})" + } + }, "spool": { "spool": "Spools", "fields": { diff --git a/client/public/locales/sv/common.json b/client/public/locales/sv/common.json index fc5d19e..e9de4a4 100644 --- a/client/public/locales/sv/common.json +++ b/client/public/locales/sv/common.json @@ -53,6 +53,58 @@ "dashboard": { "title": "Hem" }, + "printing": { + "generic": { + "title": "Utskrift", + "description": "Justera inställningarna nedan för att få önskat utskriftslayout. Kom ihåg att skrivare och ditt operativsystem kan tillämpa sina egna marginaler och skalning, så du kan behöva justera inställningarna därefter. Testa på ett vanligt papper innan du skriver ut på de faktiska etiketterna.", + "print": "Skriv ut", + "columns": "Kolumner", + "rows": "Rader", + "paperSize": "Papperstyp", + "showBorder": "Visa ram", + "previewScale": "Skala förhandsgranskning", + "marginLeft": "Marginal - vänster", + "marginRight": "Marginal - höger", + "marginTop": "Marginal - topp", + "marginBottom": "Marginal - botten", + "blockRowsTop": "Blockera rader - topp", + "blockRowsBottom": "Blockera rader - botten", + "blockColumnsLeft": "Blockera kolumner - vänster", + "blockColumnsRight": "Blockera kolumner - höger", + "borders": { + "none": "Ingen", + "border": "Ram", + "grid": "Rutnät" + } + }, + "qrcode": { + "button": "Skriv ut QR-koder", + "title": "QR-kod utskrift", + "textSize": "Textstorlek", + "showSpoolmanIcon": "Visa Spoolman-ikon", + "showContent": "Visa QR-kodsinnehåll" + }, + "spoolSelect": { + "title": "Välj spolar", + "description": "Välj spolar att skriva ut QR-koder för.", + "showArchived": "Tillåt arkiverade", + "noSpoolsSelected": "Du har inte valt några spolar.", + "selectAll": "Markera/avmarkera alla", + "selectedTotal_one": "{{count}} spole vald", + "selectedTotal_other": "{{count}} spolar valda" + } + }, + "scanner": { + "title": "Skanna QR-kod", + "description": "Skanna en QR-kod för att visa information om en spole.", + "error": { + "notAllowed": "Du har inte tillåtit åtkomst till kameran.", + "insecureContext": "Sidan måste använda sig av HTTPS.", + "streamApiNotSupported": "Webbläsaren stödjer inte MediaStream API.", + "notReadable": "Kameran är inte läsbar.", + "unknown": "Ett okänt fel inträffade. ({{error}})" + } + }, "spool": { "spool": "Spolar", "fields": { diff --git a/client/src/components/header/index.tsx b/client/src/components/header/index.tsx index e57c734..eacdd25 100644 --- a/client/src/components/header/index.tsx +++ b/client/src/components/header/index.tsx @@ -15,6 +15,7 @@ import { ColorModeContext } from "../../contexts/color-mode"; import "/node_modules/flag-icons/css/flag-icons.min.css"; import { languages } from "../../i18n"; +import QRCodeScannerModal from "../qrCodeScanner"; const { useToken } = theme; @@ -84,6 +85,7 @@ export const Header: React.FC = ({ onChange={() => setMode(mode === "light" ? "dark" : "light")} defaultChecked={mode === "dark"} /> + ); diff --git a/client/src/components/printing/printingDialog.tsx b/client/src/components/printing/printingDialog.tsx new file mode 100644 index 0000000..b95ed87 --- /dev/null +++ b/client/src/components/printing/printingDialog.tsx @@ -0,0 +1,640 @@ +import React, { useRef } from "react"; +import { + Modal, + Slider, + Button, + Select, + Row, + Col, + Form, + Divider, + RadioChangeEvent, + Radio, + InputNumber, +} from "antd"; +import ReactToPrint from "react-to-print"; +import { useSavedState } from "../../utils/saveload"; +import { useTranslate } from "@refinedev/core"; + +interface PrintingDialogProps { + items: JSX.Element[]; + style?: string; + extraSettings?: JSX.Element; + visible: boolean; + onCancel: () => void; + title?: string; +} + +interface PaperDimensions { + width: number; + height: number; +} + +const paperDimensions: { [key: string]: PaperDimensions } = { + A3: { + width: 297, + height: 420, + }, + A4: { + width: 210, + height: 297, + }, + A5: { + width: 148, + height: 210, + }, + Letter: { + width: 216, + height: 279, + }, + Legal: { + width: 216, + height: 356, + }, + Tabloid: { + width: 279, + height: 432, + }, +}; + +const PrintingDialog: React.FC = ({ + items, + style, + extraSettings, + visible, + onCancel, + title, +}) => { + const t = useTranslate(); + + const [marginLeft, setMarginLeft] = useSavedState("print-marginLeft", 10); + const [marginTop, setMarginTop] = useSavedState("print-marginTop", 10); + const [marginRight, setMarginRight] = useSavedState("print-marginRight", 10); + const [marginBottom, setMarginBottom] = useSavedState( + "print-marginBottom", + 10 + ); + const [blockRowsTop, setBlockRowsTop] = useSavedState( + "print-blockRowsTop", + 0 + ); + const [blockRowsBottom, setBlockRowsBottom] = useSavedState( + "print-blockRowsBottom", + 0 + ); + const [blockColumnsLeft, setBlockColumnsLeft] = useSavedState( + "print-blockColumnsLeft", + 0 + ); + const [blockColumnsRight, setBlockColumnsRight] = useSavedState( + "print-blockColumnsRight", + 0 + ); + const [paperColumns, setPaperColumns] = useSavedState("print-itemsPerRow", 3); + const [paperRows, setPaperRows] = useSavedState("print-rowsPerPage", 8); + const [paperSize, setPaperSize] = useSavedState("print-paperSize", "A4"); + const [previewScale, setPreviewScale] = useSavedState( + "print-previewScale", + 0.6 + ); + const [borderShowMode, setBorderShowMode] = useSavedState< + "none" | "border" | "grid" + >("print-borderShowMode", "grid"); + + const paperWidth = paperDimensions[paperSize].width; + const paperHeight = paperDimensions[paperSize].height; + + const printRef = useRef(null); + + const calculatedRowHeight = + (paperHeight - marginTop - marginBottom) / paperRows; + + const itemsPerRow = paperColumns - blockColumnsLeft - blockColumnsRight; + const rowsPerPage = paperRows - blockRowsTop - blockRowsBottom; + + const rowsOfItems = []; + for (let row_idx = 0; row_idx <= items.length / itemsPerRow; row_idx += 1) { + rowsOfItems.push( + items.slice(row_idx * itemsPerRow, (row_idx + 1) * itemsPerRow) + ); + } + + const pageBlocks = []; + for ( + let page_idx = 0; + page_idx <= rowsOfItems.length / rowsPerPage; + page_idx += 1 + ) { + pageBlocks.push( + rowsOfItems.slice(page_idx * rowsPerPage, (page_idx + 1) * rowsPerPage) + ); + } + + const pages = pageBlocks.map(function (rows, idx) { + const itemRowsBlockTop = [...Array(blockRowsTop).keys()].map((row) => ( + + +
+ + + )); + const itemRows = rows.map((row, idx) => { + const itemColumnsBlockLeft = [...Array(blockColumnsLeft).keys()].map( + (col) => ( + +
+ + ) + ); + return ( + + {itemColumnsBlockLeft} + {row.map((item, index) => ( + +
+ {item} +
+ + ))} + + ); + }); + + return ( +
+
+ + {itemRowsBlockTop} + {itemRows} +
+
+
+ ); + }); + + return ( + ( + + )} + content={() => printRef.current} + />, + ]} + width={1200} // Set the modal width to accommodate the preview + > + + {t("printing.generic.description")} + +
+
+ + {pages} +
+
+ + +
+ + + + `${value} mm` }} + value={marginLeft} + onChange={(value) => { + setMarginLeft(value); + }} + /> + + + { + setMarginLeft(value ?? 0); + }} + /> + + + + + + + `${value} mm` }} + value={marginTop} + onChange={(value) => { + setMarginTop(value); + }} + /> + + + { + setMarginTop(value ?? 0); + }} + /> + + + + + + + `${value} mm` }} + value={marginRight} + onChange={(value) => { + setMarginRight(value); + }} + /> + + + { + setMarginRight(value ?? 0); + }} + /> + + + + + + + `${value} mm` }} + value={marginBottom} + onChange={(value) => { + setMarginBottom(value); + }} + /> + + + { + setMarginBottom(value ?? 0); + }} + /> + + + + + + + { + if (value - blockColumnsLeft - blockColumnsRight < 1) + return; + setPaperColumns(value); + }} + /> + + + { + if ( + (value ?? 1) - blockColumnsLeft - blockColumnsRight < + 1 + ) + return; + setPaperColumns(value ?? 1); + }} + /> + + + + + + + { + if (value - blockRowsTop - blockRowsBottom < 1) return; + setPaperRows(value); + }} + /> + + + { + if ((value ?? 1) - blockRowsTop - blockRowsBottom < 1) + return; + setPaperRows(value ?? 1); + }} + /> + + + + + + + + { + setBorderShowMode(e.target.value); + }} + value={borderShowMode} + optionType="button" + buttonStyle="solid" + /> + + + + + { + if (paperRows - value - blockRowsBottom < 1) return; + setBlockRowsTop(value); + }} + /> + + + { + if (paperRows - (value ?? 0) - blockRowsBottom < 1) + return; + setBlockRowsTop(value ?? 0); + }} + /> + + + + + + + { + if (paperRows - value - blockRowsTop < 1) return; + setBlockRowsBottom(value); + }} + /> + + + { + if (paperRows - (value ?? 0) - blockRowsTop < 1) return; + setBlockRowsBottom(value ?? 0); + }} + /> + + + + + + + { + if (paperColumns - value - blockColumnsRight < 1) return; + setBlockColumnsLeft(value); + }} + /> + + + { + if (paperColumns - (value ?? 0) - blockColumnsRight < 1) + return; + setBlockColumnsLeft(value ?? 0); + }} + /> + + + + + + + { + if (paperColumns - value - blockColumnsLeft < 1) return; + setBlockColumnsRight(value); + }} + /> + + + { + if (paperColumns - (value ?? 0) - blockColumnsLeft < 1) + return; + setBlockColumnsRight(value ?? 0); + }} + /> + + + + {extraSettings && } + {extraSettings} + + + + + { + setPreviewScale(value); + }} + /> + + + { + setPreviewScale(value ?? 0.1); + }} + /> + + + + + +
+
+ ); +}; + +export default PrintingDialog; diff --git a/client/src/components/printing/qrCodePrintingDialog.tsx b/client/src/components/printing/qrCodePrintingDialog.tsx new file mode 100644 index 0000000..9ad30b2 --- /dev/null +++ b/client/src/components/printing/qrCodePrintingDialog.tsx @@ -0,0 +1,130 @@ +import { Col, Form, InputNumber, QRCode, Row, Slider, Switch } from "antd"; +import { useSavedState } from "../../utils/saveload"; +import PrintingDialog from "./printingDialog"; +import { useTranslate } from "@refinedev/core"; + +interface QRCodeData { + value: string; + label?: string; + errorLevel?: "L" | "M" | "Q" | "H"; +} + +interface QRCodePrintingDialogProps { + visible: boolean; + items: QRCodeData[]; + onCancel: () => void; +} + +const QRCodePrintingDialog: React.FC = ({ + visible, + items, + onCancel, +}) => { + const t = useTranslate(); + + const [showContent, setShowContent] = useSavedState( + "print-showContent", + true + ); + const [textSize, setTextSize] = useSavedState("print-textSize", 5); + const [showSpoolmanIcon, setShowSpoolmanIcon] = useSavedState( + "print-showSpoolmanIcon", + true + ); + + const elements = items.map((item) => { + return ( + <> + + {showContent && ( +
+ {item.label ?? item.value} +
+ )} + + ); + }); + + return ( + + + setShowContent(checked)} + /> + + + + + `${value} mm` }} + min={3} + max={15} + value={textSize} + step={0.1} + onChange={(value) => { + setTextSize(value); + }} + /> + + + { + setTextSize(value ?? 5); + }} + /> + + + + + setShowSpoolmanIcon(checked)} + /> + + + } + style={` + .print-page .print-qrcode { + height: 100% !important; + width: 100% !important; + } + + .print-page .print-qrcode-title { + font-size: ${textSize}mm; + } + + .print-page svg { + display: block; + height: 100%; + width: auto; + } + `} + onCancel={onCancel} + /> + ); +}; + +export default QRCodePrintingDialog; diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx new file mode 100644 index 0000000..cd09006 --- /dev/null +++ b/client/src/components/qrCodeScanner.tsx @@ -0,0 +1,82 @@ +import React, { useState } from "react"; +import { FloatButton, Modal, Space } from "antd"; +import { QrScanner } from "@yudiel/react-qr-scanner"; +import { useNavigate } from "react-router-dom"; +import { CameraOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; + +const QRCodeScannerModal: React.FC = () => { + const [visible, setVisible] = useState(false); + const [lastError, setLastError] = useState(null); + const t = useTranslate(); + const navigate = useNavigate(); + + const onScan = (result: string) => { + // Check for the spoolman ID format + const match = result.match(/^web\+spoolman:s-(?[0-9]+)$/); + if (match && match.groups) { + setVisible(false); + navigate(`/spool/show/${match.groups.id}`); + } + }; + + return ( + <> + setVisible(true)} + icon={} + shape="circle" + /> + setVisible(false)} + footer={null} + title={t("scanner.title")} + > + +

{t("scanner.description")}

+ ( +
+

{lastError}

+
+ ) + : undefined + } + onDecode={onScan} + onError={(error: Error) => { + console.error(error); + if (error.name === "NotAllowedError") { + setLastError(t("scanner.error.notAllowed")); + } else if ( + error.name === "InsecureContextError" || + (location.protocol !== "https:" && + navigator.mediaDevices === undefined) + ) { + setLastError(t("scanner.error.insecureContext")); + } else if (error.name === "StreamApiNotSupportedError") { + setLastError(t("scanner.error.streamApiNotSupported")); + } else if (error.name === "NotReadableError") { + setLastError(t("scanner.error.notReadable")); + } else { + setLastError(t("scanner.error.unknown", { error: error.name })); + } + }} + /> +
+
+ + ); +}; + +export default QRCodeScannerModal; diff --git a/client/src/components/selectAndPrintDialog.tsx b/client/src/components/selectAndPrintDialog.tsx new file mode 100644 index 0000000..01f11e6 --- /dev/null +++ b/client/src/components/selectAndPrintDialog.tsx @@ -0,0 +1,53 @@ +// React FC that combines the functionality of first selecting what spools to print QR code for, and then opens up the QR code printing dialog. + +import React from "react"; +import SpoolSelectModal from "./spoolSelectModal"; +import QRCodePrintingDialog from "./printing/qrCodePrintingDialog"; +import { Button } from "antd"; +import { ISpool } from "../pages/spools/model"; +import { PrinterOutlined } from "@ant-design/icons"; +import { useTranslate } from "@refinedev/core"; + +const SelectAndPrint: React.FC = () => { + const t = useTranslate(); + + const [step, setStep] = React.useState(0); + const [selectedSpools, setSelectedSpools] = React.useState([]); + return ( + <> + + { + setStep(0); + }} + onContinue={(spools) => { + setSelectedSpools(spools); + setStep(2); + }} + /> + { + setStep(1); + }} + items={selectedSpools.map((spool) => ({ + value: `web+spoolman:s-${spool.id}`, + label: `s-${spool.id}`, + errorLevel: "H", + }))} + /> + + ); +}; + +export default SelectAndPrint; diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx new file mode 100644 index 0000000..71faed9 --- /dev/null +++ b/client/src/components/spoolSelectModal.tsx @@ -0,0 +1,217 @@ +import React, { useState } from "react"; +import { Modal, Table, Checkbox, Space, Row, Col, message } from "antd"; +import { ISpool } from "../pages/spools/model"; +import { FilteredColumn, SortedColumn, SpoolIconColumn } from "./column"; +import { TableState } from "../utils/saveload"; +import { useTable } from "@refinedev/antd"; +import { genericSorter, typeSorters } from "../utils/sorting"; +import { genericFilterer, typeFilters } from "../utils/filtering"; +import { t } from "i18next"; + +interface Props { + visible: boolean; + description?: string; + onCancel: () => void; + onContinue: (selectedSpools: ISpool[]) => void; +} + +interface ISpoolCollapsed extends ISpool { + filament_name: string; + material?: string; +} + +const SpoolSelectModal: React.FC = ({ + visible, + description, + onCancel, + onContinue, +}) => { + const [selectedItems, setSelectedItems] = useState([]); + const [showArchived, setShowArchived] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const { tableProps, sorters, filters, current, pageSize } = useTable({ + meta: { + queryParams: { + ["allow_archived"]: showArchived, + }, + }, + syncWithLocation: false, + pagination: { + mode: "off", + current: 1, + pageSize: 10, + }, + sorters: { + mode: "off", + }, + filters: { + mode: "off", + }, + }); + + // Store state in local storage + const tableState: TableState = { + sorters, + filters, + pagination: { current, pageSize }, + }; + + // Collapse the dataSource to a mutable list and add a filament_name field + const dataSource: ISpoolCollapsed[] = React.useMemo( + () => + (tableProps.dataSource ?? []).map((element) => { + let filament_name: string; + if (element.filament.vendor && "name" in element.filament.vendor) { + filament_name = `${element.filament.vendor.name} - ${element.filament.name}`; + } else { + filament_name = + element.filament.name ?? element.filament.id.toString(); + } + return { + ...element, + filament_name, + material: element.filament.material, + }; + }), + [tableProps.dataSource] + ); + + // Type the sorters and filters + const typedSorters = typeSorters(sorters); + const typedFilters = typeFilters(filters); + + // Filter and sort the dataSource + const filteredDataSource = React.useMemo(() => { + const filtered = dataSource.filter(genericFilterer(typedFilters)); + filtered.sort(genericSorter(typedSorters)); + return filtered; + }, [dataSource, typedFilters, typedSorters]); + + // Function to add/remove all filtered items from selected items + const selectUnselectFiltered = (select: boolean) => { + setSelectedItems((prevSelected) => { + const filtered = filteredDataSource + .map((spool) => spool.id) + .filter((spool) => !prevSelected.includes(spool)); + return select ? [...prevSelected, ...filtered] : filtered; + }); + }; + + // Handler for selecting/unselecting individual items + const handleSelectItem = (item: number) => { + setSelectedItems((prevSelected) => + prevSelected.includes(item) + ? prevSelected.filter((selected) => selected !== item) + : [...prevSelected, item] + ); + }; + + // State for the select/unselect all checkbox + const isAllFilteredSelected = filteredDataSource.every((spool) => + selectedItems.includes(spool.id) + ); + + return ( + { + if (selectedItems.length === 0) { + messageApi.open({ + type: "error", + content: t("printing.spoolSelect.noSpoolsSelected"), + }); + return; + } + onContinue( + dataSource.filter((spool) => selectedItems.includes(spool.id)) + ); + }} + width={600} + > + {contextHolder} + + {description &&
{description}
} + + ( + handleSelectItem(item.id)} + /> + )} + /> + {SortedColumn({ + id: "id", + i18ncat: "spool", + dataSource, + tableState, + })} + {SpoolIconColumn({ + id: "filament_name", + i18ncat: "spool", + color: (record: ISpoolCollapsed) => record.filament.color_hex, + dataSource, + tableState, + })} + {FilteredColumn({ + id: "material", + i18ncat: "spool", + dataSource, + tableState, + })} +
+ + + { + selectUnselectFiltered(e.target.checked); + }} + > + {t("printing.spoolSelect.selectAll")} + + + +
+ {t("printing.spoolSelect.selectedTotal", { + count: selectedItems.length, + })} +
+ + + { + setShowArchived(e.target.checked); + if (!e.target.checked) { + // Remove archived spools from selected items + setSelectedItems((prevSelected) => + prevSelected.filter( + (selected) => + dataSource.find((spool) => spool.id === selected) + ?.archived !== true + ) + ); + } + }} + > + {t("printing.spoolSelect.showArchived")} + + +
+
+
+ ); +}; + +export default SpoolSelectModal; diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 0918c30..9cddaa7 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -37,6 +37,7 @@ import { SpoolIconColumn, } from "../../components/column"; import { setSpoolArchived } from "./functions"; +import SelectAndPrint from "../../components/selectAndPrintDialog"; dayjs.extend(utc); @@ -191,6 +192,7 @@ export const SpoolList: React.FC = () => { ( <> +