Inicial
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
USER root
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
USER nginx
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
NAMESERVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf)
|
||||
NAMESERVER=${NAMESERVER:-127.0.0.11}
|
||||
|
||||
sed -i "s/__RESOLVER__/$NAMESERVER/" /etc/nginx/conf.d/default.conf
|
||||
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
resolver __RESOLVER__ valid=10s ipv6=off;
|
||||
|
||||
location /api/ {
|
||||
set $backend_upstream http://backend:8080;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 5s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+5532
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-separator": "^1.1.10",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router": "^7.18.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import type { UserRole } from '@/store/authStore'
|
||||
import LoginPage from '@/pages/auth/LoginPage'
|
||||
import AppDashboardPage from '@/pages/app/DashboardPage'
|
||||
import ClientsPage from '@/pages/app/ClientsPage'
|
||||
import CatalogPage from '@/pages/app/CatalogPage'
|
||||
import WorkOrdersPage from '@/pages/app/WorkOrdersPage'
|
||||
import WorkOrderDetailPage from '@/pages/app/WorkOrderDetailPage'
|
||||
import StaffPage from '@/pages/app/StaffPage'
|
||||
import ExpensesPage from '@/pages/app/ExpensesPage'
|
||||
import SettingsPage from '@/pages/app/SettingsPage'
|
||||
import InvoicesPage from '@/pages/app/InvoicesPage'
|
||||
import AdminDashboardPage from '@/pages/admin/DashboardPage'
|
||||
import TenantsPage from '@/pages/admin/TenantsPage'
|
||||
import InviteRedeemPage from '@/pages/public/InviteRedeemPage'
|
||||
import AppLayout from '@/components/layout/AppLayout'
|
||||
import AdminLayout from '@/components/layout/AdminLayout'
|
||||
|
||||
function RequireAuth({
|
||||
children,
|
||||
allowedRoles,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
allowedRoles: UserRole[]
|
||||
}) {
|
||||
const { isAuthenticated, user } = useAuthStore()
|
||||
const location = useLocation()
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||
// Allow super_admin carrying an impersonation payload through to /app —
|
||||
// AppLayout applies the role switch on mount via useLayoutEffect.
|
||||
const hasImpersonation = !!location.state?.impersonation
|
||||
if (user && !allowedRoles.includes(user.role) && !hasImpersonation) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/invite/:token" element={<InviteRedeemPage />} />
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth allowedRoles={['super_admin']}>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="tenants" element={<TenantsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<RequireAuth allowedRoles={['tenant_admin', 'manager', 'technician']}>
|
||||
<AppLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<AppDashboardPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="catalog" element={<CatalogPage />} />
|
||||
<Route path="work-orders" element={<WorkOrdersPage />} />
|
||||
<Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
|
||||
<Route path="staff" element={<StaffPage />} />
|
||||
<Route path="expenses" element={<ExpensesPage />} />
|
||||
<Route path="invoices" element={<InvoicesPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/app" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,55 @@
|
||||
import { Outlet, NavLink } from 'react-router'
|
||||
import { useLogout } from '@/hooks/useAuth'
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { mutate: logout } = useLogout()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-950">
|
||||
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<h1 className="text-lg font-bold text-white">TechXCar</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Administração</p>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
<NavLink
|
||||
to="/admin"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/tenants"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
Oficinas
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="p-3 border-t border-slate-800">
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Terminar sessão
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto p-6 text-white">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router'
|
||||
import { useLogout } from '@/hooks/useAuth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
|
||||
const nav = [
|
||||
{ to: '/app', label: 'Dashboard', end: true },
|
||||
{ to: '/app/work-orders', label: 'Ordens de Trabalho' },
|
||||
{ to: '/app/clients', label: 'Clientes' },
|
||||
{ to: '/app/catalog', label: 'Catálogo' },
|
||||
{ to: '/app/staff', label: 'Técnicos' },
|
||||
{ to: '/app/expenses', label: 'Despesas' },
|
||||
{ to: '/app/invoices', label: 'Faturação' },
|
||||
{ to: '/app/settings', label: 'Definições' },
|
||||
]
|
||||
|
||||
export default function AppLayout() {
|
||||
const { mutate: logout } = useLogout()
|
||||
const { previousSession, restoreSession, user, impersonateTenant } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const imp = location.state?.impersonation
|
||||
if (imp) {
|
||||
impersonateTenant(imp.token, imp.user)
|
||||
queryClient.clear()
|
||||
// Remove impersonation payload from history state so back/forward doesn't re-apply it
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleRestore() {
|
||||
restoreSession()
|
||||
navigate('/admin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-950">
|
||||
{previousSession && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
|
||||
<span>
|
||||
TechXCar Admin — a gerir: <strong>{user?.name}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRestore}
|
||||
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
|
||||
>
|
||||
← Voltar ao painel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<h1 className="text-lg font-bold text-white">TechXCar</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{nav.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-3 border-t border-slate-800">
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Terminar sessão
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto p-6 text-white">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-blue-100 text-blue-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
destructive: 'bg-red-100 text-red-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
outline: 'border border-gray-300 text-gray-700',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-blue-600 text-white shadow hover:bg-blue-700',
|
||||
destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700',
|
||||
outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900',
|
||||
secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200',
|
||||
ghost: 'hover:bg-gray-100 text-gray-700',
|
||||
link: 'text-blue-600 underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-slate-900 border border-slate-700 p-6 shadow-lg rounded-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4 text-slate-400" />
|
||||
<span className="sr-only">Fechar</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-white', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useAuthStore, type AuthUser } from '@/store/authStore'
|
||||
|
||||
interface LoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
tenant_slug?: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const { setAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: LoginRequest) =>
|
||||
apiFetch<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setAuth(data.user, data.access_token)
|
||||
if (data.user.role === 'super_admin') {
|
||||
navigate('/admin', { replace: true })
|
||||
} else {
|
||||
navigate('/app', { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const { clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => apiFetch('/auth/logout', { method: 'POST' }),
|
||||
onSettled: () => {
|
||||
clearAuth()
|
||||
navigate('/login', { replace: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useClients, useClientVehicles } from './useClients'
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }))
|
||||
vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() }))
|
||||
|
||||
import { apiFetch } from '@/lib/api'
|
||||
const mockFetch = apiFetch as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeWrapper() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
return Wrapper
|
||||
}
|
||||
|
||||
describe('useClients', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('calls GET /clients', async () => {
|
||||
mockFetch.mockResolvedValue([])
|
||||
const { result } = renderHook(() => useClients(), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockFetch).toHaveBeenCalledWith('/clients')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useClientVehicles', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('calls GET /clients/:id/vehicles when clientId provided', async () => {
|
||||
mockFetch.mockResolvedValue([])
|
||||
const { result } = renderHook(() => useClientVehicles('abc-123'), { wrapper: makeWrapper() })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(mockFetch).toHaveBeenCalledWith('/clients/abc-123/vehicles')
|
||||
})
|
||||
|
||||
it('does not fetch when clientId is empty', () => {
|
||||
const { result } = renderHook(() => useClientVehicles(''), { wrapper: makeWrapper() })
|
||||
expect(result.current.fetchStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import type { Client, Vehicle } from '@/lib/types'
|
||||
|
||||
export type ClientPayload = {
|
||||
name: string; nif: string; phone: string; email: string; address: string; notes: string
|
||||
}
|
||||
export type ClientUpdatePayload = ClientPayload & { id: string }
|
||||
export type VehiclePayload = {
|
||||
plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string
|
||||
}
|
||||
export type VehicleUpdatePayload = VehiclePayload & { id: string }
|
||||
|
||||
export function useClients() {
|
||||
return useQuery<Client[]>({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useClient(id: string) {
|
||||
return useQuery<Client>({
|
||||
queryKey: ['clients', id],
|
||||
queryFn: () => apiFetch<Client>(`/clients/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateClient() {
|
||||
return useMutation({
|
||||
mutationFn: (data: ClientPayload) =>
|
||||
apiFetch<Client>('/clients', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateClient() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: ClientUpdatePayload) =>
|
||||
apiFetch<Client>(`/clients/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
onSuccess: (_result, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['clients', id] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteClient() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useClientVehicles(clientId: string) {
|
||||
return useQuery<Vehicle[]>({
|
||||
queryKey: ['clients', clientId, 'vehicles'],
|
||||
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
|
||||
enabled: !!clientId,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateVehicle(clientId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (data: VehiclePayload) =>
|
||||
apiFetch<Vehicle>(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateVehicle(clientId: string) {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: VehicleUpdatePayload) =>
|
||||
apiFetch<Vehicle>(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
useAuthStore.getState().updateToken(data.data.access_token)
|
||||
return data.data.access_token
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const { accessToken, clearAuth } = useAuthStore.getState()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
let res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.status === 401 && accessToken) {
|
||||
const newToken = await refreshAccessToken()
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
} else {
|
||||
clearAuth()
|
||||
throw new ApiError(401, 'Sessão expirada. Por favor inicie sessão novamente.')
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, json.error ?? 'Erro desconhecido')
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './api'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status < 500) return false
|
||||
return failureCount < 2
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
export interface Tenant {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
status: 'active' | 'suspended' | 'pending'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: string
|
||||
token: string
|
||||
tenant_id: string | null
|
||||
expires_at: string
|
||||
used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string
|
||||
name: string
|
||||
nif: string
|
||||
phone: string
|
||||
email: string
|
||||
address: string
|
||||
notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
id: string
|
||||
client_id: string | null
|
||||
plate: string
|
||||
brand: string
|
||||
model: string
|
||||
year: number | null
|
||||
vin: string
|
||||
mileage: number | null
|
||||
fuel_type: string
|
||||
notes: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CatalogItem {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
category: string
|
||||
unit: 'un' | 'hora' | 'litro' | 'kg'
|
||||
base_price: number
|
||||
active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WorkOrder {
|
||||
id: string
|
||||
number: number
|
||||
client_id: string | null
|
||||
vehicle_id: string | null
|
||||
status: 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled'
|
||||
internal_notes: string
|
||||
client_notes: string
|
||||
created_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WOItem {
|
||||
id: string
|
||||
work_order_id: string
|
||||
catalog_item_id: string | null
|
||||
description: string
|
||||
qty: number
|
||||
unit_price: number
|
||||
discount_pct: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface WOStaffHours {
|
||||
id: string
|
||||
work_order_id: string
|
||||
staff_id: string
|
||||
hours: number
|
||||
cost_per_hour: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface WorkOrderDetail extends WorkOrder {
|
||||
items: WOItem[]
|
||||
staff_hours: WOStaffHours[]
|
||||
}
|
||||
|
||||
export interface Staff {
|
||||
id: string
|
||||
user_id: string | null
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
type: 'internal' | 'external'
|
||||
hourly_rate: number
|
||||
active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Expense {
|
||||
id: string
|
||||
vehicle_id: string | null
|
||||
type: 'fuel' | 'parts' | 'tools' | 'other'
|
||||
amount: number
|
||||
description: string
|
||||
date: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type TenantSettings = Record<string, string>
|
||||
|
||||
export interface Invoice {
|
||||
id: string
|
||||
work_order_id: string
|
||||
type: 'quote' | 'invoice'
|
||||
number: number
|
||||
pdf_path: string
|
||||
issued_at: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AdminDashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Painel de Administração</h1>
|
||||
<p className="text-slate-400 mt-1 text-sm">Gestão da plataforma — implementado no Plano 2</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { AuthUser } from '@/store/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Tenant, Invite } from '@/lib/types'
|
||||
|
||||
function toSlug(name: string) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export default function TenantsPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [inviteToken, setInviteToken] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '', slug: '', admin_name: '', admin_email: '', admin_password: '',
|
||||
})
|
||||
const [createError, setCreateError] = useState('')
|
||||
|
||||
const { data: tenants = [], isLoading } = useQuery<Tenant[]>({
|
||||
queryKey: ['admin', 'tenants'],
|
||||
queryFn: () => apiFetch<Tenant[]>('/admin/tenants'),
|
||||
})
|
||||
|
||||
const generateInvite = useMutation({
|
||||
mutationFn: () => apiFetch<Invite>('/admin/invites', { method: 'POST' }),
|
||||
onSuccess: (invite) => {
|
||||
setInviteToken(invite.token)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'tenants'] })
|
||||
},
|
||||
})
|
||||
|
||||
const accessTenant = useMutation({
|
||||
mutationFn: (tenantId: string) =>
|
||||
apiFetch<{ access_token: string; tenant: { id: string; name: string; slug: string } }>(
|
||||
`/admin/tenants/${tenantId}/access`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
// Navigate first — auth state change happens in AppLayout after mount.
|
||||
// If impersonateTenant were called here, the admin RequireAuth would
|
||||
// see role='tenant_admin', reject it, and redirect to /login before
|
||||
// navigate('/app') takes effect.
|
||||
navigate('/app', {
|
||||
state: {
|
||||
impersonation: {
|
||||
token: data.access_token,
|
||||
user: {
|
||||
id: data.tenant.id,
|
||||
email: '',
|
||||
name: data.tenant.name,
|
||||
role: 'tenant_admin' as const,
|
||||
tenantId: data.tenant.id,
|
||||
} satisfies AuthUser,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
console.error('Erro ao aceder à oficina:', err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const createTenant = useMutation({
|
||||
mutationFn: (body: typeof createForm) =>
|
||||
apiFetch<Tenant>('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'tenants'] })
|
||||
setShowCreate(false)
|
||||
setCreateForm({ name: '', slug: '', admin_name: '', admin_email: '', admin_password: '' })
|
||||
setCreateError('')
|
||||
},
|
||||
onError: (err: Error) => setCreateError(err.message),
|
||||
})
|
||||
|
||||
const inviteUrl = inviteToken ? `${window.location.origin}/invite/${inviteToken}` : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Oficinas</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{tenants.length} oficinas registadas</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => generateInvite.mutate()} disabled={generateInvite.isPending}>
|
||||
{generateInvite.isPending ? 'A gerar...' : 'Gerar Convite'}
|
||||
</Button>
|
||||
<Button onClick={() => { setShowCreate(true); setCreateError('') }}>
|
||||
Criar Oficina
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Nova Oficina</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setCreateError('')
|
||||
createTenant.mutate(createForm)
|
||||
}}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label>Nome da oficina *</Label>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm(f => ({
|
||||
...f, name: e.target.value, slug: toSlug(e.target.value),
|
||||
}))}
|
||||
className="bg-slate-900 border-slate-600 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Slug *</Label>
|
||||
<Input
|
||||
value={createForm.slug}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, slug: e.target.value }))}
|
||||
className="bg-slate-900 border-slate-600 text-white font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Nome do admin *</Label>
|
||||
<Input
|
||||
value={createForm.admin_name}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, admin_name: e.target.value }))}
|
||||
className="bg-slate-900 border-slate-600 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Email do admin *</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.admin_email}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, admin_email: e.target.value }))}
|
||||
className="bg-slate-900 border-slate-600 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Password do admin *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={createForm.admin_password}
|
||||
onChange={(e) => setCreateForm(f => ({ ...f, admin_password: e.target.value }))}
|
||||
className="bg-slate-900 border-slate-600 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{createError && <p className="col-span-2 text-red-400 text-sm">{createError}</p>}
|
||||
<div className="col-span-2 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={createTenant.isPending}>
|
||||
{createTenant.isPending ? 'A criar...' : 'Criar Oficina'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inviteUrl && (
|
||||
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-700">
|
||||
<p className="text-slate-300 text-sm font-medium mb-2">Link de convite (válido 72h):</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs text-green-400 bg-slate-900 px-3 py-2 rounded break-all">
|
||||
{inviteUrl}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigator.clipboard.writeText(inviteUrl)}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setInviteToken(null)}
|
||||
className="text-xs text-slate-500 hover:text-slate-400 mt-2"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : tenants.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhuma oficina registada.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Slug</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.map((t) => (
|
||||
<tr key={t.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-white font-medium">{t.name}</td>
|
||||
<td className="px-4 py-3 text-slate-400 font-mono text-xs">{t.slug}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant={
|
||||
t.status === 'active'
|
||||
? 'default'
|
||||
: t.status === 'suspended'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => accessTenant.mutate(t.id)}
|
||||
disabled={accessTenant.isPending}
|
||||
>
|
||||
{accessTenant.isPending ? '...' : 'Gerir'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CatalogItem } from '@/lib/types'
|
||||
|
||||
const UNITS = ['un', 'hora', 'litro', 'kg'] as const
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'mao_de_obra', label: 'Mão de Obra' },
|
||||
{ value: 'diagnostico', label: 'Diagnóstico' },
|
||||
{ value: 'manutencao', label: 'Manutenção' },
|
||||
{ value: 'pecas_mecanicas', label: 'Peças Mecânicas' },
|
||||
{ value: 'pneus_jantes', label: 'Pneus / Jantes' },
|
||||
{ value: 'eletrica', label: 'Elétrica / Eletrónica' },
|
||||
{ value: 'fluidos', label: 'Fluidos / Óleos' },
|
||||
{ value: 'ar_condicionado', label: 'Ar Condicionado' },
|
||||
{ value: 'carrocaria', label: 'Carroçaria / Pintura' },
|
||||
{ value: 'consumiveis', label: 'Consumíveis' },
|
||||
{ value: 'acessorios', label: 'Acessórios' },
|
||||
{ value: 'outro', label: 'Outro' },
|
||||
] as const
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
|
||||
CATEGORIES.map(c => [c.value, c.label])
|
||||
)
|
||||
|
||||
const UNITS_LABEL: Record<string, string> = {
|
||||
un: 'un', hora: 'hora', litro: 'L', kg: 'kg',
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().min(1, 'Código obrigatório'),
|
||||
name: z.string().min(1, 'Nome obrigatório'),
|
||||
category: z.string().min(1, 'Categoria obrigatória'),
|
||||
unit: z.enum(UNITS),
|
||||
base_price: z.coerce.number().min(0),
|
||||
active: z.boolean(),
|
||||
})
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const emptyItem: FormData = { code: '', name: '', category: 'mao_de_obra', unit: 'un', base_price: 0, active: true }
|
||||
|
||||
export default function CatalogPage() {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState<CatalogItem | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const { data: items = [], isLoading } = useQuery<CatalogItem[]>({
|
||||
queryKey: ['catalog'],
|
||||
queryFn: () => apiFetch<CatalogItem[]>('/catalog'),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||
defaultValues: emptyItem,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
editing
|
||||
? apiFetch<CatalogItem>(`/catalog/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
|
||||
: apiFetch<CatalogItem>('/catalog', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['catalog'] })
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
reset()
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/catalog/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }),
|
||||
})
|
||||
|
||||
function openNew() {
|
||||
setEditing(null)
|
||||
reset(emptyItem)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function openEdit(item: CatalogItem) {
|
||||
setEditing(item)
|
||||
reset({ code: item.code, name: item.name, category: item.category, unit: item.unit, base_price: item.base_price, active: item.active })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Catálogo</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{items.length} itens</p>
|
||||
</div>
|
||||
<Button onClick={openNew}>Novo Item</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
{editing ? 'Editar Item' : 'Novo Item'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="code">Código *</Label>
|
||||
<Input id="code" {...register('code')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.code && <p className="text-red-400 text-xs">{errors.code.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Nome *</Label>
|
||||
<Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="category">Categoria *</Label>
|
||||
<select
|
||||
id="category"
|
||||
{...register('category')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
{errors.category && <p className="text-red-400 text-xs">{errors.category.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="unit">Unidade *</Label>
|
||||
<select
|
||||
id="unit"
|
||||
{...register('unit')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="un">Unidade (un)</option>
|
||||
<option value="hora">Hora</option>
|
||||
<option value="litro">Litro (L)</option>
|
||||
<option value="kg">Quilograma (kg)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base_price">Preço Base (€)</Label>
|
||||
<Input id="base_price" type="number" step="0.01" {...register('base_price')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="active" {...register('active')} className="h-4 w-4" />
|
||||
<Label htmlFor="active">Activo</Label>
|
||||
</div>
|
||||
{save.error && (
|
||||
<p className="col-span-2 text-red-400 text-sm">{save.error.message}</p>
|
||||
)}
|
||||
<div className="col-span-2 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{save.isPending ? 'A guardar...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhum item no catálogo.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Código</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Categoria</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Un.</th>
|
||||
<th className="text-right px-4 py-3 text-slate-400 font-medium">Preço</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-slate-300 font-mono text-xs">{item.code}</td>
|
||||
<td className="px-4 py-3 text-white">{item.name}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{CATEGORY_LABEL[item.category] ?? item.category}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{UNITS_LABEL[item.unit] ?? item.unit}</td>
|
||||
<td className="px-4 py-3 text-white text-right">{item.base_price.toFixed(2)} €</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={item.active ? 'default' : 'secondary'}>
|
||||
{item.active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 flex gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(item)}>Editar</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => { if (confirm('Eliminar item?')) remove.mutate(item.id) }}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Client, Vehicle } from '@/lib/types'
|
||||
|
||||
// ─── Client form ─────────────────────────────────────────────────────────────
|
||||
|
||||
const clientSchema = z.object({
|
||||
name: z.string().min(1, 'Nome obrigatório'),
|
||||
nif: z.string(),
|
||||
phone: z.string(),
|
||||
email: z.union([z.string().email('Email inválido'), z.literal('')]),
|
||||
address: z.string(),
|
||||
notes: z.string(),
|
||||
})
|
||||
type ClientForm = z.infer<typeof clientSchema>
|
||||
const emptyClient: ClientForm = { name: '', nif: '', phone: '', email: '', address: '', notes: '' }
|
||||
|
||||
// ─── Vehicle form ─────────────────────────────────────────────────────────────
|
||||
|
||||
const FUEL_TYPES = [
|
||||
{ value: '', label: '— Combustível —' },
|
||||
{ value: 'gasoline', label: 'Gasolina' },
|
||||
{ value: 'diesel', label: 'Diesel' },
|
||||
{ value: 'electric', label: 'Eléctrico' },
|
||||
{ value: 'hybrid', label: 'Híbrido' },
|
||||
{ value: 'hybrid_plug', label: 'Híbrido Plug-in' },
|
||||
{ value: 'lpg', label: 'GPL' },
|
||||
{ value: 'hydrogen', label: 'Hidrogénio' },
|
||||
{ value: 'bifuel', label: 'Bi-Fuel' },
|
||||
]
|
||||
|
||||
const FUEL_LABEL: Record<string, string> = Object.fromEntries(
|
||||
FUEL_TYPES.filter(f => f.value).map(f => [f.value, f.label])
|
||||
)
|
||||
|
||||
const vehicleSchema = z.object({
|
||||
plate: z.string().min(1, 'Matrícula obrigatória'),
|
||||
brand: z.string(),
|
||||
model: z.string(),
|
||||
year: z.coerce.number().int().min(1900).max(2100).or(z.literal(0)),
|
||||
vin: z.string(),
|
||||
fuel_type: z.string(),
|
||||
mileage: z.coerce.number().int().min(0).or(z.literal(0)),
|
||||
notes: z.string(),
|
||||
})
|
||||
type VehicleForm = z.infer<typeof vehicleSchema>
|
||||
const emptyVehicle: VehicleForm = { plate: '', brand: '', model: '', year: 0, vin: '', fuel_type: '', mileage: 0, notes: '' }
|
||||
|
||||
// ─── ClientVehicles sub-component ────────────────────────────────────────────
|
||||
|
||||
function ClientVehicles({ clientId }: { clientId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
|
||||
const [showVehicleForm, setShowVehicleForm] = useState(false)
|
||||
|
||||
const { data: vehicles = [], isLoading } = useQuery<Vehicle[]>({
|
||||
queryKey: ['vehicles', clientId],
|
||||
queryFn: () => apiFetch<Vehicle[]>(`/clients/${clientId}/vehicles`),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<VehicleForm>({
|
||||
resolver: zodResolver(vehicleSchema) as Resolver<VehicleForm>,
|
||||
defaultValues: emptyVehicle,
|
||||
})
|
||||
|
||||
const saveVehicle = useMutation({
|
||||
mutationFn: (data: VehicleForm) => {
|
||||
const body = {
|
||||
...data,
|
||||
year: data.year || null,
|
||||
mileage: data.mileage || null,
|
||||
}
|
||||
return editingVehicle
|
||||
? apiFetch<Vehicle>(`/vehicles/${editingVehicle.id}`, { method: 'PUT', body: JSON.stringify(body) })
|
||||
: apiFetch<Vehicle>(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(body) })
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['vehicles', clientId] })
|
||||
setShowVehicleForm(false)
|
||||
setEditingVehicle(null)
|
||||
reset(emptyVehicle)
|
||||
},
|
||||
})
|
||||
|
||||
function openNewVehicle() {
|
||||
setEditingVehicle(null)
|
||||
reset(emptyVehicle)
|
||||
setShowVehicleForm(true)
|
||||
}
|
||||
|
||||
function openEditVehicle(v: Vehicle) {
|
||||
setEditingVehicle(v)
|
||||
reset({
|
||||
plate: v.plate,
|
||||
brand: v.brand,
|
||||
model: v.model,
|
||||
year: v.year ?? 0,
|
||||
vin: v.vin,
|
||||
fuel_type: v.fuel_type,
|
||||
mileage: v.mileage ?? 0,
|
||||
notes: v.notes,
|
||||
})
|
||||
setShowVehicleForm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={5} className="bg-slate-900/60 border-t border-slate-700 px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-slate-300 text-xs font-semibold uppercase tracking-wide">
|
||||
Viaturas {!isLoading && `(${vehicles.length})`}
|
||||
</span>
|
||||
<Button size="sm" onClick={openNewVehicle}>+ Viatura</Button>
|
||||
</div>
|
||||
|
||||
{showVehicleForm && (
|
||||
<form
|
||||
onSubmit={handleSubmit((d) => saveVehicle.mutate(d))}
|
||||
className="grid grid-cols-3 gap-3 mb-4 p-4 bg-slate-800 rounded-lg border border-slate-600"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Matrícula *</Label>
|
||||
<Input {...register('plate')} placeholder="AA-00-BB"
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
{errors.plate && <p className="text-red-400 text-xs">{errors.plate.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Marca</Label>
|
||||
<Input {...register('brand')} placeholder="Toyota"
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Modelo</Label>
|
||||
<Input {...register('model')} placeholder="Corolla"
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Ano</Label>
|
||||
<Input type="number" {...register('year')} placeholder="2020"
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Km actuais</Label>
|
||||
<Input type="number" {...register('mileage')} placeholder="50000"
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">VIN / Chassi</Label>
|
||||
<Input {...register('vin')}
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Combustível</Label>
|
||||
<select {...register('fuel_type')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 text-sm h-8">
|
||||
{FUEL_TYPES.map(f => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label className="text-xs">Notas</Label>
|
||||
<Input {...register('notes')}
|
||||
className="bg-slate-900 border-slate-600 text-white text-sm h-8" />
|
||||
</div>
|
||||
{saveVehicle.error && (
|
||||
<p className="col-span-3 text-red-400 text-xs">{(saveVehicle.error as Error).message}</p>
|
||||
)}
|
||||
<div className="col-span-3 flex gap-2 justify-end">
|
||||
<Button type="button" size="sm" variant="outline"
|
||||
onClick={() => { setShowVehicleForm(false); setEditingVehicle(null) }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={saveVehicle.isPending}>
|
||||
{saveVehicle.isPending ? 'A guardar...' : editingVehicle ? 'Actualizar' : 'Adicionar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-500 text-xs">A carregar...</p>
|
||||
) : vehicles.length === 0 && !showVehicleForm ? (
|
||||
<p className="text-slate-500 text-xs">Sem viaturas. Clique em + Viatura para adicionar.</p>
|
||||
) : vehicles.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500">
|
||||
<th className="text-left py-1 pr-4 font-medium">Matrícula</th>
|
||||
<th className="text-left py-1 pr-4 font-medium">Marca / Modelo</th>
|
||||
<th className="text-left py-1 pr-4 font-medium">Ano</th>
|
||||
<th className="text-left py-1 pr-4 font-medium">Combustível</th>
|
||||
<th className="text-right py-1 pr-4 font-medium">Km</th>
|
||||
<th className="py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v) => (
|
||||
<tr key={v.id} className="border-t border-slate-800">
|
||||
<td className="py-2 pr-4 text-white font-mono font-semibold">{v.plate}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">
|
||||
{[v.brand, v.model].filter(Boolean).join(' ') || '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-400">{v.year ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-slate-400">{FUEL_LABEL[v.fuel_type] ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-slate-400 text-right">
|
||||
{v.mileage != null ? v.mileage.toLocaleString('pt-PT') + ' km' : '—'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="outline"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => openEditVehicle(v)}>
|
||||
Editar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClientsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState<Client | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const { data: clients = [], isLoading } = useQuery<Client[]>({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<ClientForm>({
|
||||
resolver: zodResolver(clientSchema),
|
||||
defaultValues: emptyClient,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (data: ClientForm) =>
|
||||
editing
|
||||
? apiFetch<Client>(`/clients/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
|
||||
: apiFetch<Client>('/clients', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['clients'] })
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
reset(emptyClient)
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
|
||||
})
|
||||
|
||||
function openNew() {
|
||||
setEditing(null)
|
||||
reset(emptyClient)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function openEdit(c: Client) {
|
||||
setEditing(c)
|
||||
reset({ name: c.name, nif: c.nif, phone: c.phone, email: c.email, address: c.address, notes: c.notes })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function toggleVehicles(clientId: string) {
|
||||
setExpandedId(prev => prev === clientId ? null : clientId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Clientes</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{clients.length} clientes</p>
|
||||
</div>
|
||||
<Button onClick={openNew}>Novo Cliente</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
{editing ? 'Editar Cliente' : 'Novo Cliente'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label htmlFor="name">Nome *</Label>
|
||||
<Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="nif">NIF</Label>
|
||||
<Input id="nif" {...register('nif')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<Input id="phone" {...register('phone')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" {...register('email')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.email && <p className="text-red-400 text-xs">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="address">Morada</Label>
|
||||
<Input id="address" {...register('address')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label htmlFor="notes">Notas</Label>
|
||||
<Input id="notes" {...register('notes')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
{save.error && (
|
||||
<p className="col-span-2 text-red-400 text-sm">{(save.error as Error).message}</p>
|
||||
)}
|
||||
<div className="col-span-2 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{save.isPending ? 'A guardar...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : clients.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhum cliente registado.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">NIF</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Telefone</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Email</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((c) => (
|
||||
<>
|
||||
<tr key={c.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-white font-medium">{c.name}</td>
|
||||
<td className="px-4 py-3 text-slate-400 font-mono text-xs">{c.nif || '—'}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{c.phone || '—'}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{c.email || '—'}</td>
|
||||
<td className="px-4 py-3 flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={expandedId === c.id ? 'secondary' : 'outline'}
|
||||
onClick={() => toggleVehicles(c.id)}
|
||||
>
|
||||
🚗 Viaturas
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(c)}>Editar</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => { if (confirm('Eliminar cliente?')) remove.mutate(c.id) }}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === c.id && <ClientVehicles key={`v-${c.id}`} clientId={c.id} />}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Bem-vindo ao TechXCar — implementado no Plano 5</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Expense } from '@/lib/types'
|
||||
|
||||
const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
fuel: 'Combustível', parts: 'Peças', tools: 'Ferramentas', other: 'Outros',
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(EXPENSE_TYPES),
|
||||
amount: z.coerce.number().positive('Valor deve ser positivo'),
|
||||
description: z.string(),
|
||||
date: z.string().min(1, 'Data obrigatória'),
|
||||
vehicle_id: z.string(),
|
||||
})
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const emptyExpense: FormData = { type: 'fuel', amount: 0, description: '', date: today, vehicle_id: '' }
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const qc = useQueryClient()
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const { data: expenses = [], isLoading } = useQuery<Expense[]>({
|
||||
queryKey: ['expenses', typeFilter],
|
||||
queryFn: () => apiFetch<Expense[]>(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||
defaultValues: emptyExpense,
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
apiFetch<Expense>('/expenses', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] })
|
||||
setShowForm(false)
|
||||
reset(emptyExpense)
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/expenses/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses'] }),
|
||||
})
|
||||
|
||||
const total = expenses.reduce((s, e) => s + e.amount, 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Despesas</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
{expenses.length} registos — total: {total.toFixed(2)} €
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => { reset(emptyExpense); setShowForm(true) }}>Nova Despesa</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['', ...EXPENSE_TYPES] as string[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
typeFilter === t
|
||||
? 'bg-slate-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t === '' ? 'Todas' : TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Nova Despesa</h2>
|
||||
<form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="type">Tipo</Label>
|
||||
<select
|
||||
id="type"
|
||||
{...register('type')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
{EXPENSE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="amount">Valor (€) *</Label>
|
||||
<Input id="amount" type="number" step="0.01" {...register('amount')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.amount && <p className="text-red-400 text-xs">{errors.amount.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="date">Data *</Label>
|
||||
<Input id="date" type="date" {...register('date')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.date && <p className="text-red-400 text-xs">{errors.date.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Descrição</Label>
|
||||
<Input id="description" {...register('description')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
{create.error && <p className="col-span-2 text-red-400 text-sm">{(create.error as Error).message}</p>}
|
||||
<div className="col-span-2 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'A guardar...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : expenses.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhuma despesa registada.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Data</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Descrição</th>
|
||||
<th className="text-right px-4 py-3 text-slate-400 font-medium">Valor</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenses.map((e) => (
|
||||
<tr key={e.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="secondary">{TYPE_LABELS[e.type]}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-300">{e.description || '—'}</td>
|
||||
<td className="px-4 py-3 text-white font-medium text-right">{e.amount.toFixed(2)} €</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => { if (confirm('Eliminar despesa?')) remove.mutate(e.id) }}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Invoice, WorkOrder } from '@/lib/types'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { quote: 'Orçamento', invoice: 'Fatura' }
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const qc = useQueryClient()
|
||||
const [showGenerate, setShowGenerate] = useState(false)
|
||||
const [selectedWO, setSelectedWO] = useState('')
|
||||
const [docType, setDocType] = useState<'quote' | 'invoice'>('quote')
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => apiFetch<Invoice[]>('/invoices'),
|
||||
})
|
||||
|
||||
const { data: workOrders = [] } = useQuery<WorkOrder[]>({
|
||||
queryKey: ['work-orders-for-invoice'],
|
||||
queryFn: () => apiFetch<WorkOrder[]>('/work-orders'),
|
||||
enabled: showGenerate,
|
||||
})
|
||||
|
||||
const eligibleWOs = workOrders.filter((wo) =>
|
||||
wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed'
|
||||
)
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<Invoice>('/invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ work_order_id: selectedWO, type: docType }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] })
|
||||
qc.invalidateQueries({ queryKey: ['work-orders'] })
|
||||
setShowGenerate(false)
|
||||
setSelectedWO('')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Faturação</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{invoices.length} documentos</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowGenerate(true)}>Gerar Documento</Button>
|
||||
</div>
|
||||
|
||||
{showGenerate && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Gerar Documento</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-300">Tipo</label>
|
||||
<select
|
||||
value={docType}
|
||||
onChange={(e) => setDocType(e.target.value as 'quote' | 'invoice')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="quote">Orçamento</option>
|
||||
<option value="invoice">Fatura</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-slate-300">Ordem de Trabalho</label>
|
||||
<select
|
||||
value={selectedWO}
|
||||
onChange={(e) => setSelectedWO(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Seleccionar OT —</option>
|
||||
{eligibleWOs.map((wo) => (
|
||||
<option key={wo.id} value={wo.id}>
|
||||
#{wo.number} ({wo.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{generate.error && (
|
||||
<p className="text-red-400 text-sm mt-3">{(generate.error as Error).message}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button variant="outline" onClick={() => { setShowGenerate(false); setSelectedWO('') }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={!selectedWO || generate.isPending}
|
||||
>
|
||||
{generate.isPending ? 'A gerar...' : 'Gerar PDF'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhum documento gerado.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nº</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">OT</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Emitida</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-white font-mono">#{inv.number}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={inv.type === 'invoice' ? 'default' : 'secondary'}>
|
||||
{TYPE_LABELS[inv.type]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 font-mono text-xs">
|
||||
{inv.work_order_id.slice(0, 8)}…
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank')}
|
||||
>
|
||||
Descarregar PDF
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useEffect } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { TenantSettings } from '@/lib/types'
|
||||
|
||||
type FormData = {
|
||||
company_name: string
|
||||
company_nif: string
|
||||
company_address: string
|
||||
company_iban: string
|
||||
company_phone: string
|
||||
company_email: string
|
||||
}
|
||||
|
||||
const defaultValues: FormData = {
|
||||
company_name: '',
|
||||
company_nif: '',
|
||||
company_address: '',
|
||||
company_iban: '',
|
||||
company_phone: '',
|
||||
company_email: '',
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: settings, isLoading } = useQuery<TenantSettings>({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => apiFetch<TenantSettings>('/settings'),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, reset } = useForm<FormData>({ defaultValues })
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
reset({
|
||||
company_name: settings['company_name'] ?? '',
|
||||
company_nif: settings['company_nif'] ?? '',
|
||||
company_address: settings['company_address'] ?? '',
|
||||
company_iban: settings['company_iban'] ?? '',
|
||||
company_phone: settings['company_phone'] ?? '',
|
||||
company_email: settings['company_email'] ?? '',
|
||||
})
|
||||
}
|
||||
}, [settings, reset])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
apiFetch<TenantSettings>('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
})
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">A carregar...</p>
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Definições</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">Dados da oficina utilizados nos documentos PDF</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((d) => save.mutate(d))}
|
||||
className="bg-slate-800 rounded-lg border border-slate-700 p-6 space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="company_name">Nome da oficina</Label>
|
||||
<Input id="company_name" {...register('company_name')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="company_nif">NIF</Label>
|
||||
<Input id="company_nif" {...register('company_nif')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="company_phone">Telefone</Label>
|
||||
<Input id="company_phone" {...register('company_phone')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="company_address">Morada</Label>
|
||||
<Input id="company_address" {...register('company_address')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="company_email">Email</Label>
|
||||
<Input id="company_email" type="email" {...register('company_email')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="company_iban">IBAN</Label>
|
||||
<Input id="company_iban" {...register('company_iban')} placeholder="PT50..."
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{save.error && (
|
||||
<p className="text-red-400 text-sm">{(save.error as Error).message}</p>
|
||||
)}
|
||||
{save.isSuccess && (
|
||||
<p className="text-green-400 text-sm">Definições guardadas.</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{save.isPending ? 'A guardar...' : 'Guardar Definições'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Staff } from '@/lib/types'
|
||||
|
||||
const STAFF_TYPES = ['internal', 'external'] as const
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Nome obrigatório'),
|
||||
email: z.string(),
|
||||
phone: z.string(),
|
||||
type: z.enum(STAFF_TYPES),
|
||||
hourly_rate: z.coerce.number().min(0),
|
||||
active: z.boolean(),
|
||||
})
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const emptyStaff: FormData = { name: '', email: '', phone: '', type: 'internal', hourly_rate: 0, active: true }
|
||||
|
||||
export default function StaffPage() {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState<Staff | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const { data: staff = [], isLoading } = useQuery<Staff[]>({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => apiFetch<Staff[]>('/staff'),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema) as Resolver<FormData>,
|
||||
defaultValues: emptyStaff,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
editing
|
||||
? apiFetch<Staff>(`/staff/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) })
|
||||
: apiFetch<Staff>('/staff', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['staff'] })
|
||||
setShowForm(false)
|
||||
setEditing(null)
|
||||
reset(emptyStaff)
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/staff/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['staff'] }),
|
||||
})
|
||||
|
||||
function openNew() {
|
||||
setEditing(null)
|
||||
reset(emptyStaff)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function openEdit(s: Staff) {
|
||||
setEditing(s)
|
||||
reset({ name: s.name, email: s.email, phone: s.phone, type: s.type, hourly_rate: s.hourly_rate, active: s.active })
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Técnicos</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{staff.length} técnicos</p>
|
||||
</div>
|
||||
<Button onClick={openNew}>Novo Técnico</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">
|
||||
{editing ? 'Editar Técnico' : 'Novo Técnico'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit((d) => save.mutate(d))} className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label htmlFor="name">Nome *</Label>
|
||||
<Input id="name" {...register('name')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" {...register('email')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<Input id="phone" {...register('phone')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="type">Tipo</Label>
|
||||
<select
|
||||
id="type"
|
||||
{...register('type')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="internal">Interno</option>
|
||||
<option value="external">Externo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hourly_rate">Custo/hora (€)</Label>
|
||||
<Input id="hourly_rate" type="number" step="0.01" {...register('hourly_rate')}
|
||||
className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="active" {...register('active')} className="h-4 w-4" />
|
||||
<Label htmlFor="active">Activo</Label>
|
||||
</div>
|
||||
{save.error && <p className="col-span-2 text-red-400 text-sm">{(save.error as Error).message}</p>}
|
||||
<div className="col-span-2 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); setEditing(null) }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={save.isPending}>
|
||||
{save.isPending ? 'A guardar...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : staff.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhum técnico registado.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nome</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Tipo</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Email</th>
|
||||
<th className="text-right px-4 py-3 text-slate-400 font-medium">€/hora</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((s) => (
|
||||
<tr key={s.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-white font-medium">{s.name}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{s.type === 'internal' ? 'Interno' : 'Externo'}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{s.email || '—'}</td>
|
||||
<td className="px-4 py-3 text-white text-right">{s.hourly_rate.toFixed(2)} €</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={s.active ? 'default' : 'secondary'}>
|
||||
{s.active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 flex gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(s)}>Editar</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => { if (confirm('Eliminar técnico?')) remove.mutate(s.id) }}
|
||||
>
|
||||
Eliminar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, Link } from 'react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, type Resolver } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types'
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
mao_de_obra: 'Mão de Obra',
|
||||
diagnostico: 'Diagnóstico',
|
||||
manutencao: 'Manutenção',
|
||||
pecas_mecanicas: 'Peças Mecânicas',
|
||||
pneus_jantes: 'Pneus / Jantes',
|
||||
eletrica: 'Elétrica / Eletrónica',
|
||||
fluidos: 'Fluidos / Óleos',
|
||||
ar_condicionado: 'Ar Condicionado',
|
||||
carrocaria: 'Carroçaria / Pintura',
|
||||
consumiveis: 'Consumíveis',
|
||||
acessorios: 'Acessórios',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Aberta',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const TRANSITIONS: Record<string, string[]> = {
|
||||
open: ['in_progress', 'cancelled'],
|
||||
in_progress: ['completed', 'cancelled'],
|
||||
completed: ['invoiced', 'cancelled'],
|
||||
invoiced: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
// ─── Item form ────────────────────────────────────────────────────────────────
|
||||
|
||||
const itemSchema = z.object({
|
||||
catalog_item_id: z.string(),
|
||||
description: z.string().min(1, 'Descrição obrigatória'),
|
||||
qty: z.coerce.number().positive('Quantidade deve ser positiva'),
|
||||
unit_price: z.coerce.number().min(0),
|
||||
discount_pct: z.coerce.number().min(0).max(100),
|
||||
})
|
||||
type ItemForm = z.infer<typeof itemSchema>
|
||||
const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 }
|
||||
|
||||
// ─── Staff hours form ─────────────────────────────────────────────────────────
|
||||
|
||||
const shSchema = z.object({
|
||||
staff_id: z.string().min(1, 'Seleccione um técnico'),
|
||||
hours: z.coerce.number().positive('Horas deve ser positivo'),
|
||||
cost_per_hour: z.coerce.number().min(0),
|
||||
})
|
||||
type SHForm = z.infer<typeof shSchema>
|
||||
const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 }
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WorkOrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const qc = useQueryClient()
|
||||
const [showAddItem, setShowAddItem] = useState(false)
|
||||
const [showAddStaff, setShowAddStaff] = useState(false)
|
||||
|
||||
const { data: detail, isLoading } = useQuery<WorkOrderDetail>({
|
||||
queryKey: ['work-order', id],
|
||||
queryFn: () => apiFetch<WorkOrderDetail>(`/work-orders/${id}`),
|
||||
})
|
||||
|
||||
const { data: catalogItems = [] } = useQuery<CatalogItem[]>({
|
||||
queryKey: ['catalog'],
|
||||
queryFn: () => apiFetch<CatalogItem[]>('/catalog'),
|
||||
})
|
||||
|
||||
const { data: staffList = [] } = useQuery<Staff[]>({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => apiFetch<Staff[]>('/staff'),
|
||||
})
|
||||
|
||||
// lookup maps
|
||||
const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c]))
|
||||
const staffMap = Object.fromEntries(staffList.map(s => [s.id, s]))
|
||||
|
||||
// ── Transitions ──────────────────────────────────────────────────────────────
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: (status: string) =>
|
||||
apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
|
||||
})
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
register: regItem,
|
||||
handleSubmit: handleItem,
|
||||
setValue: setItemVal,
|
||||
reset: resetItem,
|
||||
formState: { errors: itemErrors },
|
||||
} = useForm<ItemForm>({
|
||||
resolver: zodResolver(itemSchema) as Resolver<ItemForm>,
|
||||
defaultValues: emptyItem,
|
||||
})
|
||||
|
||||
function onCatalogSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const cid = e.target.value
|
||||
setItemVal('catalog_item_id', cid)
|
||||
if (cid) {
|
||||
const found = catalogMap[cid]
|
||||
if (found) {
|
||||
setItemVal('description', found.name)
|
||||
setItemVal('unit_price', found.base_price)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addItem = useMutation({
|
||||
mutationFn: (data: ItemForm) =>
|
||||
apiFetch(`/work-orders/${id}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['work-order', id] })
|
||||
setShowAddItem(false)
|
||||
resetItem(emptyItem)
|
||||
},
|
||||
})
|
||||
|
||||
const removeItem = useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
apiFetch(`/work-orders/${id}/items/${itemId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
|
||||
})
|
||||
|
||||
// ── Staff hours ───────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
register: regSH,
|
||||
handleSubmit: handleSH,
|
||||
setValue: setSHVal,
|
||||
reset: resetSH,
|
||||
formState: { errors: shErrors },
|
||||
} = useForm<SHForm>({
|
||||
resolver: zodResolver(shSchema) as Resolver<SHForm>,
|
||||
defaultValues: emptySH,
|
||||
})
|
||||
|
||||
function onStaffSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const sid = e.target.value
|
||||
setSHVal('staff_id', sid)
|
||||
if (sid) {
|
||||
const found = staffMap[sid]
|
||||
if (found) setSHVal('cost_per_hour', found.hourly_rate)
|
||||
}
|
||||
}
|
||||
|
||||
const addSH = useMutation({
|
||||
mutationFn: (data: SHForm) =>
|
||||
apiFetch(`/work-orders/${id}/staff-hours`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['work-order', id] })
|
||||
setShowAddStaff(false)
|
||||
resetSH(emptySH)
|
||||
},
|
||||
})
|
||||
|
||||
const removeSH = useMutation({
|
||||
mutationFn: (shId: string) =>
|
||||
apiFetch(`/work-orders/${id}/staff-hours/${shId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }),
|
||||
})
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) return <p className="text-slate-400">A carregar...</p>
|
||||
if (!detail) return <p className="text-slate-400">Ordem não encontrada.</p>
|
||||
|
||||
const itemsTotal = detail.items.reduce((s, i) => s + i.total, 0)
|
||||
const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0)
|
||||
const nextStates = TRANSITIONS[detail.status] ?? []
|
||||
const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/app/work-orders" className="text-slate-400 hover:text-white text-sm">
|
||||
← Ordens
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<h1 className="text-2xl font-bold text-white">Ordem #{detail.number}</h1>
|
||||
<Badge variant={detail.status === 'cancelled' ? 'destructive' : detail.status === 'completed' || detail.status === 'invoiced' ? 'secondary' : 'default'}>
|
||||
{STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Transitions */}
|
||||
{nextStates.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-400 text-sm self-center">Transição:</span>
|
||||
{nextStates.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={s === 'cancelled' ? 'destructive' : 'default'}
|
||||
onClick={() => {
|
||||
if (s === 'cancelled' && !confirm('Cancelar esta ordem?')) return
|
||||
transition.mutate(s)
|
||||
}}
|
||||
disabled={transition.isPending}
|
||||
>
|
||||
→ {STATUS_LABELS[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Items ─────────────────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white">Itens</h2>
|
||||
{editable && (
|
||||
<Button size="sm" onClick={() => { resetItem(emptyItem); setShowAddItem(true) }}>
|
||||
Adicionar Item
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddItem && (
|
||||
<div className="mb-4 bg-slate-800 rounded-lg border border-slate-700 p-4">
|
||||
<form onSubmit={handleItem((d) => addItem.mutate(d))} className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Catálogo (opcional)</Label>
|
||||
<select
|
||||
{...regItem('catalog_item_id')}
|
||||
onChange={onCatalogSelect}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Seleccionar do catálogo —</option>
|
||||
{catalogItems.filter(i => i.active).map(i => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.code} — {i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Descrição *</Label>
|
||||
<Input {...regItem('description')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{itemErrors.description && <p className="text-red-400 text-xs">{itemErrors.description.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Qtd. *</Label>
|
||||
<Input type="number" step="0.001" {...regItem('qty')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{itemErrors.qty && <p className="text-red-400 text-xs">{itemErrors.qty.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preço Unit. (€)</Label>
|
||||
<Input type="number" step="0.01" {...regItem('unit_price')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Desconto (%)</Label>
|
||||
<Input type="number" step="0.01" {...regItem('discount_pct')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
{addItem.error && <p className="col-span-3 text-red-400 text-sm">{(addItem.error as Error).message}</p>}
|
||||
<div className="col-span-3 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddItem(false)}>Cancelar</Button>
|
||||
<Button type="submit" size="sm" disabled={addItem.isPending}>
|
||||
{addItem.isPending ? 'A adicionar...' : 'Adicionar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.items.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Sem itens.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Artigo</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Qtd.</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Preço Unit.</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Desc.%</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Total</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.items.map((item) => {
|
||||
const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null
|
||||
return (
|
||||
<tr key={item.id} className="border-t border-slate-700">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-white">{item.description}</div>
|
||||
{cat && (
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{CATEGORY_LABEL[cat.category] ?? cat.category}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{item.qty}</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{item.unit_price.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{item.discount_pct}%</td>
|
||||
<td className="px-4 py-2 text-white font-medium text-right">{item.total.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{editable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300 h-7 px-2"
|
||||
onClick={() => removeItem.mutate(item.id)}
|
||||
disabled={removeItem.isPending}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-800/50">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-2 text-slate-400 text-right font-medium">Subtotal itens</td>
|
||||
<td className="px-4 py-2 text-white font-bold text-right">{itemsTotal.toFixed(2)} €</td>
|
||||
<td />
|
||||
</tr>
|
||||
{hoursTotal > 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-2 text-slate-400 text-right font-medium">Mão de obra</td>
|
||||
<td className="px-4 py-2 text-white font-bold text-right">{hoursTotal.toFixed(2)} €</td>
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-2 text-slate-300 text-right font-bold">Total</td>
|
||||
<td className="px-4 py-2 text-blue-400 font-bold text-right text-base">
|
||||
{(itemsTotal + hoursTotal).toFixed(2)} €
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Staff hours ───────────────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white">Técnicos</h2>
|
||||
{editable && (
|
||||
<Button size="sm" onClick={() => { resetSH(emptySH); setShowAddStaff(true) }}>
|
||||
Adicionar Técnico
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddStaff && (
|
||||
<div className="mb-4 bg-slate-800 rounded-lg border border-slate-700 p-4">
|
||||
<form onSubmit={handleSH((d) => addSH.mutate(d))} className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label>Técnico *</Label>
|
||||
<select
|
||||
{...regSH('staff_id')}
|
||||
onChange={onStaffSelect}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Seleccionar técnico —</option>
|
||||
{staffList.filter(s => s.active).map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.type === 'internal' ? 'Interno' : 'Externo'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{shErrors.staff_id && <p className="text-red-400 text-xs">{shErrors.staff_id.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Horas *</Label>
|
||||
<Input type="number" step="0.25" {...regSH('hours')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
{shErrors.hours && <p className="text-red-400 text-xs">{shErrors.hours.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>€/hora</Label>
|
||||
<Input type="number" step="0.01" {...regSH('cost_per_hour')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
<div className="flex items-end pb-0.5">
|
||||
<p className="text-slate-500 text-xs">Preço preenchido automaticamente<br/>do cadastro do técnico</p>
|
||||
</div>
|
||||
{addSH.error && <p className="col-span-3 text-red-400 text-sm">{(addSH.error as Error).message}</p>}
|
||||
<div className="col-span-3 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddStaff(false)}>Cancelar</Button>
|
||||
<Button type="submit" size="sm" disabled={addSH.isPending}>
|
||||
{addSH.isPending ? 'A adicionar...' : 'Adicionar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.staff_hours.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Sem técnicos registados nesta ordem.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-slate-400 font-medium">Técnico</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Horas</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">€/hora</th>
|
||||
<th className="text-right px-4 py-2 text-slate-400 font-medium">Total</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.staff_hours.map((sh) => {
|
||||
const staff = staffMap[sh.staff_id]
|
||||
return (
|
||||
<tr key={sh.id} className="border-t border-slate-700">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-white">{staff?.name ?? '—'}</div>
|
||||
{staff && (
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{staff.type === 'internal' ? 'Interno' : 'Externo'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-white text-right">{sh.hours}</td>
|
||||
<td className="px-4 py-2 text-slate-300 text-right">{sh.cost_per_hour.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-white font-medium text-right">{sh.total.toFixed(2)} €</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{editable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300 h-7 px-2"
|
||||
onClick={() => removeSH.mutate(sh.id)}
|
||||
disabled={removeSH.isPending}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { WorkOrder, Client, Vehicle } from '@/lib/types'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Aberta',
|
||||
in_progress: 'Em Curso',
|
||||
completed: 'Concluída',
|
||||
invoiced: 'Faturada',
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive'> = {
|
||||
open: 'secondary',
|
||||
in_progress: 'default',
|
||||
completed: 'default',
|
||||
invoiced: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
client_id: z.string(),
|
||||
vehicle_id: z.string(),
|
||||
internal_notes: z.string(),
|
||||
})
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '' }
|
||||
|
||||
export default function WorkOrdersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const { data: orders = [], isLoading } = useQuery<WorkOrder[]>({
|
||||
queryKey: ['work-orders', statusFilter],
|
||||
queryFn: () => apiFetch<WorkOrder[]>(`/work-orders${statusFilter ? `?status=${statusFilter}` : ''}`),
|
||||
})
|
||||
|
||||
const { data: clients = [] } = useQuery<Client[]>({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiFetch<Client[]>('/clients'),
|
||||
})
|
||||
|
||||
const { register, handleSubmit, watch, reset } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: emptyOrder,
|
||||
})
|
||||
|
||||
const selectedClientId = watch('client_id')
|
||||
|
||||
const { data: vehicles = [] } = useQuery<Vehicle[]>({
|
||||
queryKey: ['vehicles', selectedClientId],
|
||||
queryFn: () => apiFetch<Vehicle[]>(`/clients/${selectedClientId}/vehicles`),
|
||||
enabled: !!selectedClientId,
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
apiFetch<WorkOrder>('/work-orders', { method: 'POST', body: JSON.stringify(data) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['work-orders'] })
|
||||
setShowForm(false)
|
||||
reset()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Ordens de Trabalho</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">{orders.length} ordens</p>
|
||||
</div>
|
||||
<Button onClick={() => { reset(emptyOrder); setShowForm(true) }}>Nova Ordem</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
{['', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
statusFilter === s
|
||||
? 'bg-slate-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{s === '' ? 'Todas' : STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 bg-slate-800 rounded-lg border border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Nova Ordem de Trabalho</h2>
|
||||
<form onSubmit={handleSubmit((d) => create.mutate(d))} className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="client_id">Cliente</Label>
|
||||
<select
|
||||
id="client_id"
|
||||
{...register('client_id')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Sem cliente —</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vehicle_id">Veículo</Label>
|
||||
<select
|
||||
id="vehicle_id"
|
||||
{...register('vehicle_id')}
|
||||
className="w-full rounded-md border border-slate-600 bg-slate-900 text-white px-3 py-2 text-sm"
|
||||
disabled={!selectedClientId}
|
||||
>
|
||||
<option value="">— Sem veículo —</option>
|
||||
{vehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.plate} — {v.brand} {v.model}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label htmlFor="internal_notes">Notas internas</Label>
|
||||
<Input id="internal_notes" {...register('internal_notes')} className="bg-slate-900 border-slate-600 text-white" />
|
||||
</div>
|
||||
{create.error && (
|
||||
<p className="col-span-2 text-red-400 text-sm">{create.error.message}</p>
|
||||
)}
|
||||
<div className="col-span-2 flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'A criar...' : 'Criar Ordem'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-slate-400">A carregar...</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">Nenhuma ordem de trabalho.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Nº</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Estado</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 font-medium">Criada</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="border-t border-slate-700 hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 text-white font-mono">#{o.number}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[o.status]}>
|
||||
{STATUS_LABELS[o.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400">
|
||||
{new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link
|
||||
to={`/app/work-orders/${o.id}`}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Ver detalhe →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useLogin } from '@/hooks/useAuth'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(1, 'Password obrigatória'),
|
||||
tenant_slug: z.string().optional(),
|
||||
})
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showSlug, setShowSlug] = useState(false)
|
||||
const { mutate: login, isPending, error } = useLogin()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
login({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
tenant_slug: data.tenant_slug || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Gestão de Oficina</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" type="email" autoComplete="email" {...register('email')} />
|
||||
{errors.email && <p className="text-red-500 text-xs">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && <p className="text-red-500 text-xs">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
{showSlug && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tenant_slug">Workspace (slug da oficina)</Label>
|
||||
<Input
|
||||
id="tenant_slug"
|
||||
type="text"
|
||||
placeholder="minha-oficina"
|
||||
{...register('tenant_slug')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center">{error.message}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? 'A entrar...' : 'Entrar'}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSlug((v) => !v)}
|
||||
className="w-full text-xs text-gray-400 hover:text-gray-600 text-center"
|
||||
>
|
||||
{showSlug ? 'Ocultar campo workspace' : 'Entrar numa oficina específica'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { Invite } from '@/lib/types'
|
||||
|
||||
const schema = z.object({
|
||||
tenant_name: z.string().min(2, 'Nome obrigatório (mínimo 2 caracteres)'),
|
||||
tenant_slug: z
|
||||
.string()
|
||||
.min(2, 'Slug obrigatório')
|
||||
.regex(/^[a-z0-9-]+$/, 'Apenas letras minúsculas, números e hífens'),
|
||||
admin_email: z.string().email('Email inválido'),
|
||||
admin_password: z.string().min(8, 'Mínimo 8 caracteres'),
|
||||
admin_name: z.string().min(2, 'Nome obrigatório'),
|
||||
})
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
interface RedeemResponse {
|
||||
access_token: string
|
||||
tenant_slug: string
|
||||
}
|
||||
|
||||
export default function InviteRedeemPage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { setAuth } = useAuthStore()
|
||||
|
||||
const {
|
||||
data: invite,
|
||||
isLoading,
|
||||
error: inviteError,
|
||||
} = useQuery<Invite>({
|
||||
queryKey: ['invite', token],
|
||||
queryFn: () => apiFetch<Invite>(`/invites/${token}`),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({ resolver: zodResolver(schema) })
|
||||
|
||||
const redeem = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
apiFetch<RedeemResponse>(`/invites/${token}/redeem`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setAuth(
|
||||
{ id: '', email: '', name: '', role: 'tenant_admin', tenantId: undefined },
|
||||
data.access_token,
|
||||
)
|
||||
navigate('/app', { replace: true })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<p className="text-gray-500">A validar convite...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (inviteError || !invite) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Convite inválido</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm">
|
||||
Este convite não existe, expirou ou já foi utilizado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">TechXCar</h1>
|
||||
<p className="text-gray-500 mt-1 text-sm">Criar conta da oficina</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => redeem.mutate(data))}
|
||||
className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4"
|
||||
>
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
|
||||
Dados da oficina
|
||||
</legend>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tenant_name">Nome da oficina</Label>
|
||||
<Input id="tenant_name" {...register('tenant_name')} placeholder="Oficina XYZ" />
|
||||
{errors.tenant_name && (
|
||||
<p className="text-red-500 text-xs">{errors.tenant_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tenant_slug">Identificador (slug)</Label>
|
||||
<Input id="tenant_slug" {...register('tenant_slug')} placeholder="oficina-xyz" />
|
||||
<p className="text-gray-400 text-xs">
|
||||
Usado no URL — apenas letras minúsculas, números e hífens.
|
||||
</p>
|
||||
{errors.tenant_slug && (
|
||||
<p className="text-red-500 text-xs">{errors.tenant_slug.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-semibold text-gray-700 pb-1 border-b border-gray-100 w-full">
|
||||
Conta de administrador
|
||||
</legend>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin_name">Nome</Label>
|
||||
<Input id="admin_name" {...register('admin_name')} placeholder="João Silva" />
|
||||
{errors.admin_name && (
|
||||
<p className="text-red-500 text-xs">{errors.admin_name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin_email">Email</Label>
|
||||
<Input id="admin_email" type="email" {...register('admin_email')} />
|
||||
{errors.admin_email && (
|
||||
<p className="text-red-500 text-xs">{errors.admin_email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin_password">Password</Label>
|
||||
<Input id="admin_password" type="password" {...register('admin_password')} />
|
||||
{errors.admin_password && (
|
||||
<p className="text-red-500 text-xs">{errors.admin_password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{redeem.error && (
|
||||
<p className="text-red-500 text-sm text-center">{redeem.error.message}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={redeem.isPending}>
|
||||
{redeem.isPending ? 'A criar conta...' : 'Criar conta'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
previousSession: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts unauthenticated', () => {
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.isAuthenticated).toBe(false)
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('setAuth stores user and token', () => {
|
||||
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
|
||||
useAuthStore.getState().setAuth(user, 'tok123')
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.user).toEqual(user)
|
||||
expect(state.accessToken).toBe('tok123')
|
||||
})
|
||||
|
||||
it('clearAuth resets to unauthenticated', () => {
|
||||
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
|
||||
useAuthStore.getState().setAuth(user, 'tok123')
|
||||
useAuthStore.getState().clearAuth()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.isAuthenticated).toBe(false)
|
||||
expect(state.user).toBeNull()
|
||||
expect(state.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('updateToken replaces only the token', () => {
|
||||
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
|
||||
useAuthStore.getState().setAuth(user, 'old-token')
|
||||
useAuthStore.getState().updateToken('new-token')
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.accessToken).toBe('new-token')
|
||||
expect(state.user).toEqual(user)
|
||||
})
|
||||
|
||||
it('impersonateTenant saves previous session and sets new auth', () => {
|
||||
const originalUser = { id: '1', email: 'admin@a.com', name: 'Admin', role: 'super_admin' as const }
|
||||
const tenantUser = { id: '2', email: 'tenant@a.com', name: 'Tenant User', role: 'tenant_admin' as const, tenantId: 'tenant-123' }
|
||||
|
||||
// Setup original session
|
||||
useAuthStore.getState().setAuth(originalUser, 'admin-token')
|
||||
|
||||
// Impersonate tenant
|
||||
useAuthStore.getState().impersonateTenant('tenant-token', tenantUser)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toEqual(tenantUser)
|
||||
expect(state.accessToken).toBe('tenant-token')
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.previousSession).toEqual({
|
||||
token: 'admin-token',
|
||||
user: originalUser,
|
||||
})
|
||||
})
|
||||
|
||||
it('restoreSession restores previous session and clears previousSession', () => {
|
||||
const originalUser = { id: '1', email: 'admin@a.com', name: 'Admin', role: 'super_admin' as const }
|
||||
const tenantUser = { id: '2', email: 'tenant@a.com', name: 'Tenant User', role: 'tenant_admin' as const, tenantId: 'tenant-123' }
|
||||
|
||||
// Setup original session and impersonate
|
||||
useAuthStore.getState().setAuth(originalUser, 'admin-token')
|
||||
useAuthStore.getState().impersonateTenant('tenant-token', tenantUser)
|
||||
|
||||
// Restore
|
||||
useAuthStore.getState().restoreSession()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.user).toEqual(originalUser)
|
||||
expect(state.accessToken).toBe('admin-token')
|
||||
expect(state.isAuthenticated).toBe(true)
|
||||
expect(state.previousSession).toBeNull()
|
||||
})
|
||||
|
||||
it('restoreSession when no previousSession is a no-op', () => {
|
||||
const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const }
|
||||
useAuthStore.getState().setAuth(user, 'tok123')
|
||||
|
||||
const stateBefore = useAuthStore.getState()
|
||||
useAuthStore.getState().restoreSession()
|
||||
const stateAfter = useAuthStore.getState()
|
||||
|
||||
expect(stateAfter.user).toEqual(stateBefore.user)
|
||||
expect(stateAfter.accessToken).toEqual(stateBefore.accessToken)
|
||||
expect(stateAfter.isAuthenticated).toBe(true)
|
||||
expect(stateAfter.previousSession).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: UserRole
|
||||
tenantId?: string
|
||||
}
|
||||
|
||||
interface PreviousSession {
|
||||
token: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null
|
||||
accessToken: string | null
|
||||
isAuthenticated: boolean
|
||||
previousSession: PreviousSession | null
|
||||
setAuth: (user: AuthUser, accessToken: string) => void
|
||||
clearAuth: () => void
|
||||
updateToken: (accessToken: string) => void
|
||||
impersonateTenant: (token: string, user: AuthUser) => void
|
||||
restoreSession: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
isAuthenticated: false,
|
||||
previousSession: null,
|
||||
setAuth: (user, accessToken) =>
|
||||
set({ user, accessToken, isAuthenticated: true, previousSession: null }),
|
||||
clearAuth: () =>
|
||||
set({ user: null, accessToken: null, isAuthenticated: false, previousSession: null }),
|
||||
updateToken: (accessToken) =>
|
||||
set({ accessToken }),
|
||||
impersonateTenant: (token, user) => {
|
||||
const { accessToken, user: currentUser } = get()
|
||||
set({
|
||||
accessToken: token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
previousSession: currentUser && accessToken
|
||||
? { token: accessToken, user: currentUser }
|
||||
: null,
|
||||
})
|
||||
},
|
||||
restoreSession: () => {
|
||||
const { previousSession } = get()
|
||||
if (!previousSession) return
|
||||
set({
|
||||
accessToken: previousSession.token,
|
||||
user: previousSession.user,
|
||||
isAuthenticated: true,
|
||||
previousSession: null,
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'techxcar-auth',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
// previousSession intentionally excluded — impersonation does not survive refresh
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
get length() { return Object.keys(store).length },
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/AdminLayout.tsx","./src/components/layout/AppLayout.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/hooks/useAuth.ts","./src/hooks/useClients.test.ts","./src/hooks/useClients.ts","./src/lib/api.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.tsx","./src/pages/app/WorkOrderDetailPage.tsx","./src/pages/app/WorkOrdersPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/public/InviteRedeemPage.tsx","./src/store/authStore.test.ts","./src/store/authStore.ts","./src/test/setup.ts"],"version":"6.0.3"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user