init project
This commit is contained in:
commit
bc1c92a329
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
frontend/.env
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@ -0,0 +1,10 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "start"]
|
||||
515
backend/data.json
Normal file
515
backend/data.json
Normal file
@ -0,0 +1,515 @@
|
||||
{
|
||||
"id": 2,
|
||||
"customer": {
|
||||
"id": 2,
|
||||
"civility": null,
|
||||
"name": "Artcurial",
|
||||
"phone": null,
|
||||
"phone_secondary": null,
|
||||
"email": "aoliveux@artcurial.com",
|
||||
"email_secondary": null,
|
||||
"language": "fr",
|
||||
"is_npai": false,
|
||||
"origin": null,
|
||||
"comments": null,
|
||||
"additional_interests": null,
|
||||
"categories": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Postgraffiti",
|
||||
"image_name": null,
|
||||
"is_for_purchase": false,
|
||||
"position": null
|
||||
}
|
||||
],
|
||||
"street": null,
|
||||
"additional_street": null,
|
||||
"zip_code": null,
|
||||
"city": null,
|
||||
"country": "FR",
|
||||
"identity_type": null,
|
||||
"identity_number": null,
|
||||
"identity_authority": null,
|
||||
"date_identity_number": null,
|
||||
"tva_intra": null,
|
||||
"code_comptable": null,
|
||||
"code_comptable_achat": null,
|
||||
"contact_first_name": "Arnaud",
|
||||
"contact_last_name": "Oliveux",
|
||||
"discr": "professionalCustomer"
|
||||
},
|
||||
"products": [
|
||||
{
|
||||
"id": 2,
|
||||
"product": {
|
||||
"id": 2,
|
||||
"code": "2018-12-0001",
|
||||
"artist": {
|
||||
"id": 26,
|
||||
"first_name": null,
|
||||
"last_name": "HENRY CHALFANT",
|
||||
"date_birth": "1940-01-01T00:00:00+01:00",
|
||||
"date_death": null,
|
||||
"image_name": null,
|
||||
"authenticity_certificate": null,
|
||||
"product_phare": null,
|
||||
"is_phare": false,
|
||||
"category": null,
|
||||
"is_circa": false
|
||||
},
|
||||
"state": {
|
||||
"name": "En vente",
|
||||
"color": "#5799b5"
|
||||
},
|
||||
"acquisition_mode": {
|
||||
"id": 1,
|
||||
"name": "Achat",
|
||||
"type": "ACQUISITION"
|
||||
},
|
||||
"capital_gain_case": "NONE",
|
||||
"acquisition_price": 1690,
|
||||
"sale_deposit_price": null,
|
||||
"category": {
|
||||
"id": 12,
|
||||
"name": "Autre",
|
||||
"image_name": null,
|
||||
"is_for_purchase": true,
|
||||
"position": null
|
||||
},
|
||||
"type": {
|
||||
"id": 6,
|
||||
"name": "Photographie"
|
||||
},
|
||||
"title": "Dondi",
|
||||
"date_first_meeting": "2018-12-20T00:00:00+01:00",
|
||||
"width": 64,
|
||||
"height": 11,
|
||||
"depth": 0,
|
||||
"diametre": null,
|
||||
"width_inch": null,
|
||||
"height_inch": null,
|
||||
"depth_inch": null,
|
||||
"diametre_inch": null,
|
||||
"encadrement": false,
|
||||
"collection_perso": false,
|
||||
"vente_debout": false,
|
||||
"storage_area": null,
|
||||
"on_wall": false,
|
||||
"authenticity_certificate": null,
|
||||
"state_description": null,
|
||||
"book_police_number": null,
|
||||
"barcode": null,
|
||||
"wanted_price": 5500,
|
||||
"wanted_price_minimum": null,
|
||||
"comments": null,
|
||||
"comment_history": null,
|
||||
"date_acquisition": "2018-11-19T00:00:00+01:00",
|
||||
"image_name": "89545966d5bb1698feb7d173f044bd19-henry-chalfat-do.png",
|
||||
"artist_phares": [],
|
||||
"contracts": [],
|
||||
"is_multiple": false,
|
||||
"product_parent": null,
|
||||
"year": "2018-12-20T00:00:00+01:00",
|
||||
"is_circa": false
|
||||
},
|
||||
"acquisition_price": 1690,
|
||||
"quantity": null
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"product": {
|
||||
"id": 3,
|
||||
"code": "2018-12-0002",
|
||||
"artist": {
|
||||
"id": 26,
|
||||
"first_name": null,
|
||||
"last_name": "HENRY CHALFANT",
|
||||
"date_birth": "1940-01-01T00:00:00+01:00",
|
||||
"date_death": null,
|
||||
"image_name": null,
|
||||
"authenticity_certificate": null,
|
||||
"product_phare": null,
|
||||
"is_phare": false,
|
||||
"category": null,
|
||||
"is_circa": false
|
||||
},
|
||||
"state": {
|
||||
"name": "En vente",
|
||||
"color": "#5799b5"
|
||||
},
|
||||
"acquisition_mode": {
|
||||
"id": 1,
|
||||
"name": "Achat",
|
||||
"type": "ACQUISITION"
|
||||
},
|
||||
"capital_gain_case": "NONE",
|
||||
"acquisition_price": 1040,
|
||||
"sale_deposit_price": null,
|
||||
"category": {
|
||||
"id": 12,
|
||||
"name": "Autre",
|
||||
"image_name": null,
|
||||
"is_for_purchase": true,
|
||||
"position": null
|
||||
},
|
||||
"type": {
|
||||
"id": 6,
|
||||
"name": "Photographie"
|
||||
},
|
||||
"title": "Futura 2000 and Kel Mare",
|
||||
"date_first_meeting": "2018-11-19T00:00:00+01:00",
|
||||
"width": 64.5,
|
||||
"height": 11,
|
||||
"depth": 0,
|
||||
"diametre": null,
|
||||
"width_inch": null,
|
||||
"height_inch": null,
|
||||
"depth_inch": null,
|
||||
" diametre_inch": null,
|
||||
"encadrement": false,
|
||||
"collection_perso": false,
|
||||
"vente_debout": false,
|
||||
"storage_area": null,
|
||||
"on_wall": false,
|
||||
"authenticity_certificate": null,
|
||||
"state_description": null,
|
||||
"book_police_number": null,
|
||||
"barcode": null,
|
||||
"wanted_price": 3500,
|
||||
"wanted_price_minimum": null,
|
||||
"comments": "Art Train Museum 1986",
|
||||
"comment_history": "Art Train Museum 1986",
|
||||
"date_acquisition": "2018-12-20T00:00:00+01:00",
|
||||
"image_name": "b35e16aee25e4633712f0bd2ddc0d7fe-henry-chalfant-futura-kel-m.png",
|
||||
"artist_phares": [],
|
||||
"contracts": [],
|
||||
"is_multiple": false,
|
||||
"product_parent": null,
|
||||
"year": "1986-01-01T00:00:00+01:00",
|
||||
"is_circa": false
|
||||
},
|
||||
"acquisition_price": 1040,
|
||||
"quantity": null
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"product": {
|
||||
"id": 4,
|
||||
"code": "2018-12-0003",
|
||||
"artist": {
|
||||
"id": 26,
|
||||
"first_name": null,
|
||||
"last_name": "HENRY CHALFANT",
|
||||
"date_birth": "1940-01-01T00:00:00+01:00",
|
||||
"date_death": null,
|
||||
"image_name": null,
|
||||
"authenticity_certificate": null,
|
||||
"product_phare": null,
|
||||
"is_phare": false,
|
||||
"category": null,
|
||||
"is_circa": false
|
||||
},
|
||||
"state": {
|
||||
"name": "En vente",
|
||||
"color": "#5799b5"
|
||||
},
|
||||
"acquisition_mode": {
|
||||
"id": 1,
|
||||
"name": "Achat",
|
||||
"type": "ACQUISITION"
|
||||
},
|
||||
"capital_gain_case": "NONE",
|
||||
"acquisition_price": 1300,
|
||||
"sale_deposit_price": null,
|
||||
"category": {
|
||||
"id": 12,
|
||||
"name": "Autre",
|
||||
"image_name": null,
|
||||
"is_for_purchase": true,
|
||||
"position": null
|
||||
},
|
||||
"type": {
|
||||
"id": 6,
|
||||
"name": "Photographie"
|
||||
},
|
||||
"title": "Lee",
|
||||
"date_first_meeting": "2018-12-20T00:00:00+01:00",
|
||||
"width": 70,
|
||||
"height": 11,
|
||||
"depth": 0,
|
||||
"diametre": null,
|
||||
"width_inch": null,
|
||||
"height_inch": null,
|
||||
"depth_inch": null,
|
||||
"diametre_inch": null,
|
||||
"encadrement": false,
|
||||
"collection_perso": false,
|
||||
"vente_debout": false,
|
||||
"storage_area": null,
|
||||
"on_wall": false,
|
||||
"authenticity_certificate": null,
|
||||
"state_description": null,
|
||||
"book_police_number": null,
|
||||
"barcode": null,
|
||||
"wanted_price": 3500,
|
||||
"wanted_price_minimum": null,
|
||||
"comments": "Art Train Museum 1986 Lee Quinones",
|
||||
"comment_history": "Art Train Museum 1986 Lee Quinones",
|
||||
"date_acquisition": "2018-12-20T00:00:00+01:00",
|
||||
"image_name": "75df0baee1fed8f83927371a05a87c0a-henry-chalfant-l.png",
|
||||
"artist_phares": [],
|
||||
"contracts": [],
|
||||
"is_multiple": false,
|
||||
"product_parent": null,
|
||||
"year": "1986-01-01T00:00:00+01:00",
|
||||
"is_circa": false
|
||||
},
|
||||
"acquisition_price": 1300,
|
||||
"quantity": null
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"product": {
|
||||
"id": 5,
|
||||
"code": "2018-12-0004",
|
||||
"artist": {
|
||||
"id": 26,
|
||||
"first_name": null,
|
||||
"last_name": "HENRY CHALFANT",
|
||||
"date_birth": "1940-01-01T00:00:00+01:00",
|
||||
"date_death": null,
|
||||
"image_name": null,
|
||||
"authenticity_certificate": null,
|
||||
"product_phare": null,
|
||||
"is_phare": false,
|
||||
"category": null,
|
||||
"is_circa": false
|
||||
},
|
||||
"state": {
|
||||
"name": "En vente",
|
||||
"color": "#5799b5"
|
||||
},
|
||||
"acquisition_mode": {
|
||||
"id": 1,
|
||||
"name": "Achat",
|
||||
"type": "ACQUISITION"
|
||||
},
|
||||
"capital_gain_case": "NONE",
|
||||
"acquisition_price": 1040,
|
||||
"sale_deposit_price": null,
|
||||
"category": {
|
||||
"id": 12,
|
||||
"name": "Autre",
|
||||
"image_name": null,
|
||||
"is_for_purchase": true,
|
||||
"position": null
|
||||
},
|
||||
"type": {
|
||||
"id": 6,
|
||||
"name": "Photographie"
|
||||
},
|
||||
"title": "Phase 2 et Delta 2",
|
||||
"date_first_meeting": "2018-12-20T00:00:00+01:00",
|
||||
"width": 49,
|
||||
"height": 9.5,
|
||||
"depth": 0,
|
||||
"diametre": null,
|
||||
"width_inch": null,
|
||||
"height_inch": null,
|
||||
"depth_inch": null,
|
||||
"diametre_inch": null,
|
||||
"encadrement": false,
|
||||
"collection_perso": false,
|
||||
"vente_debout": false,
|
||||
"storage_area": null,
|
||||
"on_wall": false,
|
||||
"authenticity_certificate": null,
|
||||
"state_description": null,
|
||||
"book_police_number": null,
|
||||
"barcode": null,
|
||||
"wanted_price": 3000,
|
||||
"wanted_price_minimum": null,
|
||||
"comments": "Art Train Museum 1986",
|
||||
"comment_history": "Art Train Museum 1986",
|
||||
"date_acquisition": "2018-12-20T00:00:00+01:00",
|
||||
"image_name": "0e6b6033f37c9e53571c1149e4585e13-henry-chafant-deltat-ph.png",
|
||||
"artist_phares": [],
|
||||
"contracts": [],
|
||||
"is_multiple": false,
|
||||
"product_parent": null,
|
||||
"year": "1986-01-01T00:00:00+01:00",
|
||||
"is_circa": false
|
||||
},
|
||||
"acquisition_price": 1040,
|
||||
"quantity": null
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"product": {
|
||||
"id": 6,
|
||||
"code": "2018-12-0005",
|
||||
"artist": {
|
||||
"id": 27,
|
||||
"first_name": "William Cordero",
|
||||
"last_name": "BILL BLAST",
|
||||
"date_birth": "1964-01-01T00:00:00+01:00",
|
||||
"date_death": null,
|
||||
"image_name": null,
|
||||
"authenticity_certificate": null,
|
||||
"product_phare": null,
|
||||
"is_phare": false,
|
||||
"category": null,
|
||||
"is_circa": false
|
||||
},
|
||||
"state": {
|
||||
"name": "En vente",
|
||||
"color": "#5799b5"
|
||||
},
|
||||
"acquisition_mode": {
|
||||
"id": 1,
|
||||
"name": "Achat",
|
||||
"type": "ACQUISITION"
|
||||
},
|
||||
"capital_gain_case": "NONE",
|
||||
"acquisition_price": 10400,
|
||||
"sale_deposit_price": null,
|
||||
"category": {
|
||||
"id": 12,
|
||||
"name": "Autre",
|
||||
"image_name": null,
|
||||
"is_for_purchase": true,
|
||||
"position": null
|
||||
},
|
||||
"type": {
|
||||
"id": 1,
|
||||
"name": "Tableau"
|
||||
},
|
||||
"title": "Self portrait",
|
||||
"date_first_meeting": "2018-12-20T00:00:00+01:00",
|
||||
"width": 205.5,
|
||||
"height": 125,
|
||||
"depth": 0,
|
||||
"diametre": null,
|
||||
"width_inch": null,
|
||||
"height_inch": null,
|
||||
"depth_inch": null,
|
||||
"diametre_inch": null,
|
||||
"encadrement": false,
|
||||
"collection_perso": false,
|
||||
"vente_debout": false,
|
||||
"storage_area": null,
|
||||
"on_wall": false,
|
||||
"authenticity_certificate": null,
|
||||
"state_description": null,
|
||||
"book_police_number": null,
|
||||
"barcode": null,
|
||||
"wanted_price": 25000,
|
||||
"wanted_price_minimum": null,
|
||||
"comments": null,
|
||||
"comment_history": null,
|
||||
"date_acquisition": "2018-12-20T00:00:00+01:00",
|
||||
"image_name": "cc8e20e2da8f1304b20761cc57fd6034-bill-bl.png",
|
||||
"artist_phares": [],
|
||||
"contracts": [],
|
||||
"is_multiple": false,
|
||||
"product_parent": null,
|
||||
"year": "2018-01-01T00:00:00+01:00",
|
||||
"is_circa": false
|
||||
},
|
||||
"acquisition_price": 10400,
|
||||
"quantity": null
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"product": {
|
||||
"id": 7,
|
||||
"code": "2018-12-0006",
|
||||
"artist": {
|
||||
"id": 7,
|
||||
"first_name": "Anthony Clark",
|
||||
"last_name": "A-ONE",
|
||||
"date_birth": "1964-01-01T00:00:00+01:00",
|
||||
"date_death": "2001-11-11T00:00:00+01:00",
|
||||
"image_name": null,
|
||||
"authenticity_certificate": null,
|
||||
"product_phare": null,
|
||||
"is_phare": false,
|
||||
"category": null,
|
||||
"is_circa": false
|
||||
},
|
||||
"state": {
|
||||
"name": "En vente",
|
||||
"color": "#5799b5"
|
||||
},
|
||||
"acquisition_mode": {
|
||||
"id": 1,
|
||||
"name": "Achat",
|
||||
"type": "ACQUISITION"
|
||||
},
|
||||
"capital_gain_case": "NONE",
|
||||
"acquisition_price": 27300,
|
||||
"sale_deposit_price": null,
|
||||
"category": {
|
||||
"id": 12,
|
||||
"name": "Autre",
|
||||
"image_name": null,
|
||||
"is_for_purchase": true,
|
||||
"position": null
|
||||
},
|
||||
"type": {
|
||||
"id": 1,
|
||||
"name": "Tableau"
|
||||
},
|
||||
"title": "Knockem out the box",
|
||||
"date_first_meeting": "2013-11-19T00:00:00+01:00",
|
||||
"width": 250,
|
||||
"height": 141,
|
||||
"depth": 0,
|
||||
"diametre": null,
|
||||
"width_inch": null,
|
||||
"height_inch": null,
|
||||
"depth_inch": null,
|
||||
"diametre_inch": null,
|
||||
"encadrement": false,
|
||||
"collection_perso": false,
|
||||
"vente_debout": false,
|
||||
"storage_area": null,
|
||||
"on_wall": false,
|
||||
"authenticity_certificate": null,
|
||||
"state_description": null,
|
||||
"book_police_number": null,
|
||||
"barcode": null,
|
||||
"wanted_price": 38000,
|
||||
"wanted_price_minimum": null,
|
||||
"comments": null,
|
||||
"comment_history": null,
|
||||
"date_acquisition": "2018-12-20T00:00:00+01:00",
|
||||
"image_name": "5c1b62faa600e_A-one Knockem out the box .png",
|
||||
"artist_phares": [],
|
||||
"contracts": [],
|
||||
"is_multiple": false,
|
||||
"product_parent": null,
|
||||
"year": "2018-01-01T00:00:00+01:00",
|
||||
"is_circa": false
|
||||
},
|
||||
"acquisition_price": 27300,
|
||||
"quantity": null
|
||||
}
|
||||
],
|
||||
"acquisition_mode": {},
|
||||
"is_grouped_purchase": false,
|
||||
"customer_name": "Artcurial",
|
||||
"customer_street": null,
|
||||
"customer_additional_street": null,
|
||||
"customer_zip_code": null,
|
||||
"customer_city": null,
|
||||
"customer_country": "FR",
|
||||
"customer_identity_type": null,
|
||||
"customer_identity_number": null,
|
||||
"customer_identity_authority": null,
|
||||
"customer_date_identity_number": null,
|
||||
"location": "Marseille - France",
|
||||
"amount_total": 42984.5,
|
||||
"amount_decremented": 42984.5,
|
||||
"from_depot_vente": false
|
||||
}
|
||||
0
backend/data/db.sqlite
Normal file
0
backend/data/db.sqlite
Normal file
2314
backend/package-lock.json
generated
Normal file
2314
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
backend/package.json
Normal file
26
backend/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/index.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@prisma/client": "^6.0.1",
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"fastify": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"prisma": "^6.0.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
BIN
backend/src/data/db.sqlite
Normal file
BIN
backend/src/data/db.sqlite
Normal file
Binary file not shown.
122
backend/src/index.js
Normal file
122
backend/src/index.js
Normal file
@ -0,0 +1,122 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const Fastify = require('fastify')
|
||||
const cors = require('@fastify/cors')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const bcrypt = require('bcrypt')
|
||||
|
||||
// Import des routes
|
||||
const customerRoutes = require('./routes/customers')
|
||||
const productRoutes = require('./routes/products')
|
||||
const purchaseRoutes = require('./routes/purchases')
|
||||
const artistRoutes = require('./routes/artists')
|
||||
const userRoutes = require('./routes/users')
|
||||
const clientRoutes = require('./routes/clients')
|
||||
const prisma = new PrismaClient()
|
||||
const fastify = Fastify({ logger: true })
|
||||
|
||||
// Plugins
|
||||
fastify.register(cors)
|
||||
|
||||
// Middleware JWT
|
||||
const JWT_SECRET = 'votre_secret_jwt'
|
||||
const authenticateToken = async (request, reply) => {
|
||||
const authHeader = request.headers.authorization
|
||||
const token = authHeader && authHeader.split(' ')[1]
|
||||
|
||||
if (!token) {
|
||||
reply.code(401).send({ error: 'Token requis' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const user = jwt.verify(token, JWT_SECRET)
|
||||
request.user = user
|
||||
} catch (err) {
|
||||
reply.code(403).send({ error: 'Token invalide' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Route de login non protégée
|
||||
fastify.post('/api/login', async (request, reply) => {
|
||||
const { email, password } = request.body
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
reply.code(401).send({ error: 'Email ou mot de passe incorrect' })
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier le mot de passe avec bcrypt
|
||||
const validPassword = await bcrypt.compare(password, user.password)
|
||||
if (!validPassword) {
|
||||
reply.code(401).send({ error: 'Email ou mot de passe incorrect' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
)
|
||||
|
||||
return { token }
|
||||
})
|
||||
|
||||
// Route de création d'utilisateur non protégée
|
||||
fastify.register(userRoutes, {
|
||||
prefix: '/api',
|
||||
publicRoutes: true
|
||||
})
|
||||
|
||||
// Route /me pour vérifier le token JWT
|
||||
fastify.get('/api/me', async (request, reply) => {
|
||||
const authHeader = request.headers.authorization
|
||||
const token = authHeader && authHeader.split(' ')[1]
|
||||
|
||||
if (!token) {
|
||||
reply.code(401).send({ error: 'Token requis' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const user = jwt.verify(token, JWT_SECRET)
|
||||
// Récupérer les infos user à jour depuis la DB
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: user.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true
|
||||
}
|
||||
})
|
||||
return currentUser
|
||||
} catch (err) {
|
||||
reply.code(401).send({ error: 'Token invalide' })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Bloc des routes protégées
|
||||
fastify.register(async (fastify) => {
|
||||
fastify.addHook('preHandler', authenticateToken)
|
||||
|
||||
// Routes existantes
|
||||
fastify.register(customerRoutes, { prefix: '/api' })
|
||||
fastify.register(productRoutes, { prefix: '/api' })
|
||||
fastify.register(purchaseRoutes, { prefix: '/api' })
|
||||
fastify.register(artistRoutes, { prefix: '/api' })
|
||||
fastify.register(clientRoutes, { prefix: '/api' })
|
||||
// Nouvelle route users (protégée)
|
||||
// fastify.register(userRoutes, { prefix: '/api' })
|
||||
})
|
||||
|
||||
// Démarrage du serveur
|
||||
fastify.listen({ port: 3000, host: '0.0.0.0' })
|
||||
@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
@ -0,0 +1,177 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Customer" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"civility" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT,
|
||||
"phoneSecondary" TEXT,
|
||||
"email" TEXT,
|
||||
"emailSecondary" TEXT,
|
||||
"language" TEXT NOT NULL DEFAULT 'fr',
|
||||
"isNpai" BOOLEAN NOT NULL DEFAULT false,
|
||||
"origin" TEXT,
|
||||
"comments" TEXT,
|
||||
"additionalInterests" TEXT,
|
||||
"street" TEXT,
|
||||
"additionalStreet" TEXT,
|
||||
"zipCode" TEXT,
|
||||
"city" TEXT,
|
||||
"country" TEXT,
|
||||
"identityType" TEXT,
|
||||
"identityNumber" TEXT,
|
||||
"identityAuthority" TEXT,
|
||||
"dateIdentityNumber" DATETIME,
|
||||
"tvaIntra" TEXT,
|
||||
"codeComptable" TEXT,
|
||||
"codeComptableAchat" TEXT,
|
||||
"contactFirstName" TEXT,
|
||||
"contactLastName" TEXT,
|
||||
"discr" TEXT NOT NULL DEFAULT 'professionalCustomer'
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"imageName" TEXT,
|
||||
"isForPurchase" BOOLEAN NOT NULL DEFAULT false,
|
||||
"position" INTEGER
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Artist" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"firstName" TEXT,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"dateBirth" DATETIME,
|
||||
"dateDeath" DATETIME,
|
||||
"imageName" TEXT,
|
||||
"authenticityCertificate" TEXT,
|
||||
"isPhare" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isCirca" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"acquisitionPrice" REAL NOT NULL,
|
||||
"saleDepositPrice" REAL,
|
||||
"dateFirstMeeting" DATETIME,
|
||||
"dateAcquisition" DATETIME,
|
||||
"width" REAL,
|
||||
"height" REAL,
|
||||
"depth" REAL,
|
||||
"diametre" REAL,
|
||||
"widthInch" REAL,
|
||||
"heightInch" REAL,
|
||||
"depthInch" REAL,
|
||||
"diametreInch" REAL,
|
||||
"encadrement" BOOLEAN NOT NULL DEFAULT false,
|
||||
"collectionPerso" BOOLEAN NOT NULL DEFAULT false,
|
||||
"venteDebout" BOOLEAN NOT NULL DEFAULT false,
|
||||
"onWall" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isMultiple" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isCirca" BOOLEAN NOT NULL DEFAULT false,
|
||||
"wantedPrice" REAL,
|
||||
"wantedPriceMinimum" REAL,
|
||||
"comments" TEXT,
|
||||
"commentHistory" TEXT,
|
||||
"imageName" TEXT,
|
||||
"year" DATETIME,
|
||||
"artistId" INTEGER NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"typeId" INTEGER NOT NULL,
|
||||
"stateId" INTEGER NOT NULL,
|
||||
"acquisitionModeId" INTEGER NOT NULL,
|
||||
"productParentId" INTEGER,
|
||||
CONSTRAINT "Product_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Type" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "State" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_acquisitionModeId_fkey" FOREIGN KEY ("acquisitionModeId") REFERENCES "AcquisitionMode" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_productParentId_fkey" FOREIGN KEY ("productParentId") REFERENCES "Product" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Type" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "State" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AcquisitionMode" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Purchase" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"isGroupedPurchase" BOOLEAN NOT NULL DEFAULT false,
|
||||
"location" TEXT,
|
||||
"amountTotal" REAL NOT NULL,
|
||||
"amountDecremented" REAL NOT NULL,
|
||||
"fromDepotVente" BOOLEAN NOT NULL DEFAULT false,
|
||||
"customerId" INTEGER NOT NULL,
|
||||
"acquisitionModeId" INTEGER,
|
||||
CONSTRAINT "Purchase_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Purchase_acquisitionModeId_fkey" FOREIGN KEY ("acquisitionModeId") REFERENCES "AcquisitionMode" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseProduct" (
|
||||
"purchaseId" INTEGER NOT NULL,
|
||||
"productId" INTEGER NOT NULL,
|
||||
"acquisitionPrice" REAL NOT NULL,
|
||||
"quantity" INTEGER,
|
||||
|
||||
PRIMARY KEY ("purchaseId", "productId"),
|
||||
CONSTRAINT "PurchaseProduct_purchaseId_fkey" FOREIGN KEY ("purchaseId") REFERENCES "Purchase" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "PurchaseProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_CategoryToCustomer" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" INTEGER NOT NULL,
|
||||
CONSTRAINT "_CategoryToCustomer_A_fkey" FOREIGN KEY ("A") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "_CategoryToCustomer_B_fkey" FOREIGN KEY ("B") REFERENCES "Customer" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"role" TEXT NOT NULL DEFAULT 'USER',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "id", "name", "password", "updatedAt") SELECT "createdAt", "email", "id", "name", "password", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_CategoryToCustomer_AB_unique" ON "_CategoryToCustomer"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_CategoryToCustomer_B_index" ON "_CategoryToCustomer"("B");
|
||||
@ -0,0 +1,58 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Client" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Product" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"acquisitionPrice" REAL NOT NULL,
|
||||
"saleDepositPrice" REAL,
|
||||
"dateFirstMeeting" DATETIME,
|
||||
"dateAcquisition" DATETIME,
|
||||
"width" REAL,
|
||||
"height" REAL,
|
||||
"depth" REAL,
|
||||
"diametre" REAL,
|
||||
"widthInch" REAL,
|
||||
"heightInch" REAL,
|
||||
"depthInch" REAL,
|
||||
"diametreInch" REAL,
|
||||
"encadrement" BOOLEAN NOT NULL DEFAULT false,
|
||||
"collectionPerso" BOOLEAN NOT NULL DEFAULT false,
|
||||
"venteDebout" BOOLEAN NOT NULL DEFAULT false,
|
||||
"onWall" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isMultiple" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isCirca" BOOLEAN NOT NULL DEFAULT false,
|
||||
"wantedPrice" REAL,
|
||||
"wantedPriceMinimum" REAL,
|
||||
"comments" TEXT,
|
||||
"commentHistory" TEXT,
|
||||
"imageName" TEXT,
|
||||
"year" DATETIME,
|
||||
"artistId" INTEGER NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"typeId" INTEGER NOT NULL,
|
||||
"stateId" INTEGER NOT NULL,
|
||||
"acquisitionModeId" INTEGER NOT NULL,
|
||||
"clientId" INTEGER,
|
||||
"productParentId" INTEGER,
|
||||
CONSTRAINT "Product_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Type" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_stateId_fkey" FOREIGN KEY ("stateId") REFERENCES "State" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_acquisitionModeId_fkey" FOREIGN KEY ("acquisitionModeId") REFERENCES "AcquisitionMode" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Product_productParentId_fkey" FOREIGN KEY ("productParentId") REFERENCES "Product" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Product" ("acquisitionModeId", "acquisitionPrice", "artistId", "categoryId", "code", "collectionPerso", "commentHistory", "comments", "dateAcquisition", "dateFirstMeeting", "depth", "depthInch", "diametre", "diametreInch", "encadrement", "height", "heightInch", "id", "imageName", "isCirca", "isMultiple", "onWall", "productParentId", "saleDepositPrice", "stateId", "title", "typeId", "venteDebout", "wantedPrice", "wantedPriceMinimum", "width", "widthInch", "year") SELECT "acquisitionModeId", "acquisitionPrice", "artistId", "categoryId", "code", "collectionPerso", "commentHistory", "comments", "dateAcquisition", "dateFirstMeeting", "depth", "depthInch", "diametre", "diametreInch", "encadrement", "height", "heightInch", "id", "imageName", "isCirca", "isMultiple", "onWall", "productParentId", "saleDepositPrice", "stateId", "title", "typeId", "venteDebout", "wantedPrice", "wantedPriceMinimum", "width", "widthInch", "year" FROM "Product";
|
||||
DROP TABLE "Product";
|
||||
ALTER TABLE "new_Product" RENAME TO "Product";
|
||||
CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Client" ADD COLUMN "url" TEXT;
|
||||
3
backend/src/prisma/migrations/migration_lock.toml
Normal file
3
backend/src/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
176
backend/src/prisma/schema.prisma
Normal file
176
backend/src/prisma/schema.prisma
Normal file
@ -0,0 +1,176 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:../data/db.sqlite"
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id Int @id @default(autoincrement())
|
||||
civility String?
|
||||
name String
|
||||
phone String?
|
||||
phoneSecondary String?
|
||||
email String?
|
||||
emailSecondary String?
|
||||
language String @default("fr")
|
||||
isNpai Boolean @default(false)
|
||||
origin String?
|
||||
comments String?
|
||||
additionalInterests String?
|
||||
street String?
|
||||
additionalStreet String?
|
||||
zipCode String?
|
||||
city String?
|
||||
country String?
|
||||
identityType String?
|
||||
identityNumber String?
|
||||
identityAuthority String?
|
||||
dateIdentityNumber DateTime?
|
||||
tvaIntra String?
|
||||
codeComptable String?
|
||||
codeComptableAchat String?
|
||||
contactFirstName String?
|
||||
contactLastName String?
|
||||
discr String @default("professionalCustomer")
|
||||
categories Category[]
|
||||
purchases Purchase[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
imageName String?
|
||||
isForPurchase Boolean @default(false)
|
||||
position Int?
|
||||
customers Customer[]
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Artist {
|
||||
id Int @id @default(autoincrement())
|
||||
firstName String?
|
||||
lastName String
|
||||
dateBirth DateTime?
|
||||
dateDeath DateTime?
|
||||
imageName String?
|
||||
authenticityCertificate String?
|
||||
isPhare Boolean @default(false)
|
||||
isCirca Boolean @default(false)
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Client {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
products Product[]
|
||||
url String?
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
title String
|
||||
acquisitionPrice Float
|
||||
saleDepositPrice Float?
|
||||
dateFirstMeeting DateTime?
|
||||
dateAcquisition DateTime?
|
||||
width Float?
|
||||
height Float?
|
||||
depth Float?
|
||||
diametre Float?
|
||||
widthInch Float?
|
||||
heightInch Float?
|
||||
depthInch Float?
|
||||
diametreInch Float?
|
||||
encadrement Boolean @default(false)
|
||||
collectionPerso Boolean @default(false)
|
||||
venteDebout Boolean @default(false)
|
||||
onWall Boolean @default(false)
|
||||
isMultiple Boolean @default(false)
|
||||
isCirca Boolean @default(false)
|
||||
wantedPrice Float?
|
||||
wantedPriceMinimum Float?
|
||||
comments String?
|
||||
commentHistory String?
|
||||
imageName String?
|
||||
year DateTime?
|
||||
|
||||
artist Artist @relation(fields: [artistId], references: [id])
|
||||
artistId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int
|
||||
type Type @relation(fields: [typeId], references: [id])
|
||||
typeId Int
|
||||
state State @relation(fields: [stateId], references: [id])
|
||||
stateId Int
|
||||
acquisitionMode AcquisitionMode @relation(fields: [acquisitionModeId], references: [id])
|
||||
acquisitionModeId Int
|
||||
|
||||
client Client? @relation(fields: [clientId], references: [id])
|
||||
clientId Int?
|
||||
|
||||
purchases PurchaseProduct[]
|
||||
productParent Product? @relation("ProductToProduct", fields: [productParentId], references: [id])
|
||||
productParentId Int?
|
||||
childProducts Product[] @relation("ProductToProduct")
|
||||
}
|
||||
|
||||
model Type {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model State {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
color String
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model AcquisitionMode {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
type String
|
||||
products Product[]
|
||||
purchases Purchase[]
|
||||
}
|
||||
|
||||
model Purchase {
|
||||
id Int @id @default(autoincrement())
|
||||
isGroupedPurchase Boolean @default(false)
|
||||
location String?
|
||||
amountTotal Float
|
||||
amountDecremented Float
|
||||
fromDepotVente Boolean @default(false)
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
customerId Int
|
||||
acquisitionMode AcquisitionMode? @relation(fields: [acquisitionModeId], references: [id])
|
||||
acquisitionModeId Int?
|
||||
products PurchaseProduct[]
|
||||
}
|
||||
|
||||
model PurchaseProduct {
|
||||
purchase Purchase @relation(fields: [purchaseId], references: [id])
|
||||
purchaseId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
productId Int
|
||||
acquisitionPrice Float
|
||||
quantity Int?
|
||||
|
||||
@@id([purchaseId, productId])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
name String?
|
||||
role String @default("USER")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
16
backend/src/routes/artists.js
Normal file
16
backend/src/routes/artists.js
Normal file
@ -0,0 +1,16 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
module.exports = async function artistRoutes(fastify) {
|
||||
fastify.post('/artists', async (request, reply) => {
|
||||
try {
|
||||
const artist = await prisma.artist.create({
|
||||
data: request.body
|
||||
})
|
||||
return artist
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
47
backend/src/routes/clients.js
Normal file
47
backend/src/routes/clients.js
Normal file
@ -0,0 +1,47 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function clientRoutes(fastify) {
|
||||
// Create client
|
||||
fastify.post('/clients', async (request, reply) => {
|
||||
try {
|
||||
const { name, url } = request.body;
|
||||
const client = await prisma.client.create({
|
||||
data: { name, url }
|
||||
});
|
||||
return client;
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: 'Failed to create client' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all clients
|
||||
fastify.get('/clients', async () => {
|
||||
try {
|
||||
return await prisma.client.findMany();
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch clients');
|
||||
}
|
||||
});
|
||||
|
||||
// Get client by ID
|
||||
fastify.get('/clients/:id', async (request, reply) => {
|
||||
try {
|
||||
const client = await prisma.client.findUnique({
|
||||
where: { id: parseInt(request.params.id) }
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
reply.code(404).send({ error: 'Client not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: 'Failed to fetch client' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = clientRoutes;
|
||||
21
backend/src/routes/customers.js
Normal file
21
backend/src/routes/customers.js
Normal file
@ -0,0 +1,21 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
module.exports = async function customerRoutes(fastify) {
|
||||
fastify.post('/customers', async (request, reply) => {
|
||||
try {
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
...request.body,
|
||||
categories: {
|
||||
connect: request.body.categoryIds?.map((id) => ({ id }))
|
||||
}
|
||||
}
|
||||
})
|
||||
return customer
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
25
backend/src/routes/products.js
Normal file
25
backend/src/routes/products.js
Normal file
@ -0,0 +1,25 @@
|
||||
const { PrismaClient } =require('@prisma/client')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
module.exports = async function productRoutes(fastify) {
|
||||
fastify.post('/products', async (request, reply) => {
|
||||
try {
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
...request.body,
|
||||
artist: { connect: { id: request.body.artistId } },
|
||||
category: { connect: { id: request.body.categoryId } },
|
||||
type: { connect: { id: request.body.typeId } },
|
||||
state: { connect: { id: request.body.stateId } },
|
||||
acquisitionMode: { connect: { id: request.body.acquisitionModeId } },
|
||||
productParent: request.body.productParentId ?
|
||||
{ connect: { id: request.body.productParentId } } : undefined
|
||||
}
|
||||
})
|
||||
return product
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
62
backend/src/routes/purchases.js
Normal file
62
backend/src/routes/purchases.js
Normal file
@ -0,0 +1,62 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function purchaseRoutes(fastify) {
|
||||
fastify.post('/purchases', async (request, reply) => {
|
||||
try {
|
||||
const purchase = await prisma.purchase.create({
|
||||
data: {
|
||||
...request.body,
|
||||
customer: { connect: { id: request.body.customerId } },
|
||||
acquisitionMode: request.body.acquisitionModeId ?
|
||||
{ connect: { id: request.body.acquisitionModeId } } : undefined,
|
||||
products: {
|
||||
create: request.body.products.map((product) => ({
|
||||
product: { connect: { id: product.productId } },
|
||||
acquisitionPrice: product.acquisitionPrice,
|
||||
quantity: product.quantity
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
products: {
|
||||
include: {
|
||||
product: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return purchase
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/purchases', async (request, reply) => {
|
||||
try {
|
||||
const purchases = await prisma.purchase.findMany({
|
||||
include: {
|
||||
customer: true,
|
||||
acquisitionMode: true,
|
||||
products: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
artist: true,
|
||||
category: true,
|
||||
client: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
return purchases
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = purchaseRoutes
|
||||
46
backend/src/routes/users.js
Normal file
46
backend/src/routes/users.js
Normal file
@ -0,0 +1,46 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const bcrypt = require('bcrypt')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
module.exports = async function userRoutes(fastify) {
|
||||
fastify.post('/users', async (request, reply) => {
|
||||
try {
|
||||
const { email, password, name, role } = request.body
|
||||
|
||||
// Vérifier si l'utilisateur existe déjà
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
reply.code(400).send({ error: 'Cet email est déjà utilisé' })
|
||||
return
|
||||
}
|
||||
|
||||
// Hasher le mot de passe
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
// Créer l'utilisateur
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
role
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
reply.code(400).send({ error: error.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
227
backend/src/utils/seed.js
Normal file
227
backend/src/utils/seed.js
Normal file
@ -0,0 +1,227 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const axios = require('axios');
|
||||
|
||||
// const data = require('../../data.json')
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function cleanDb() {
|
||||
// Suppression dans l'ordre pour respecter les contraintes de clés étrangères
|
||||
await prisma.purchaseProduct.deleteMany()
|
||||
await prisma.purchase.deleteMany()
|
||||
await prisma.product.deleteMany()
|
||||
await prisma.artist.deleteMany()
|
||||
await prisma.category.deleteMany()
|
||||
await prisma.state.deleteMany()
|
||||
await prisma.type.deleteMany()
|
||||
await prisma.acquisitionMode.deleteMany()
|
||||
await prisma.customer.deleteMany()
|
||||
console.log('Base de données nettoyée!')
|
||||
}
|
||||
|
||||
const API_URL = 'https://lh.logiciel-arteo.fr/api/purchase/';
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
// await cleanDb()
|
||||
|
||||
// Créer le client par défaut
|
||||
// const defaultClient = await prisma.client.upsert({
|
||||
// where: { id: 1 },
|
||||
// update: {},
|
||||
// create: {
|
||||
// id: 1,
|
||||
// name: "Ghost",
|
||||
// url: API_URL
|
||||
// }
|
||||
// })
|
||||
|
||||
const response = await axios.get(API_URL);
|
||||
const dataArray = response.data;
|
||||
|
||||
for (const data of dataArray) {
|
||||
// 1. Create Categories
|
||||
const allCategories = new Set([
|
||||
...(data.customer?.categories || []),
|
||||
...(data.products || []).map(p => p.product?.category).filter(Boolean)
|
||||
])
|
||||
|
||||
const categories = await Promise.all(
|
||||
Array.from(allCategories).map(cat =>
|
||||
prisma.category.upsert({
|
||||
where: { id: cat?.id || 0 },
|
||||
update: {},
|
||||
create: {
|
||||
id: cat?.id || 0,
|
||||
name: cat?.name || 'Unknown',
|
||||
imageName: cat?.image_name || null,
|
||||
isForPurchase: cat?.is_for_purchase || false,
|
||||
position: cat?.position || null
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 2. Create Customer
|
||||
if (data.customer) {
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { id: data.customer?.id || 0 },
|
||||
update: {},
|
||||
create: {
|
||||
id: data.customer?.id || 0,
|
||||
name: data.customer?.name || 'Unknown',
|
||||
email: data.customer?.email,
|
||||
language: data.customer?.language || 'fr',
|
||||
isNpai: data.customer?.is_npai || false,
|
||||
country: data.customer?.country,
|
||||
contactFirstName: data.customer?.contact_first_name,
|
||||
contactLastName: data.customer?.contact_last_name,
|
||||
discr: data.customer?.discr || 'professionalCustomer',
|
||||
categories: {
|
||||
connect: (data.customer?.categories || []).map(cat => ({ id: cat.id }))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Skip if no products
|
||||
if (!data.products || !data.products.length) {
|
||||
console.log('No products found for this entry, skipping product creation')
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Create States, Types, and AcquisitionModes
|
||||
for (const product of data.products) {
|
||||
if (product.product?.state) {
|
||||
await prisma.state.upsert({
|
||||
where: { id: product.product.state?.id || 1 },
|
||||
update: {},
|
||||
create: {
|
||||
id: product.product.state?.id || 1,
|
||||
name: product.product.state?.name || 'Unknown',
|
||||
color: product.product.state?.color || '#000000'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (product.product?.type) {
|
||||
await prisma.type.upsert({
|
||||
where: { id: product.product.type?.id || 1 },
|
||||
update: {},
|
||||
create: {
|
||||
id: product.product.type?.id || 1,
|
||||
name: product.product.type?.name || 'Unknown'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (product.product?.acquisition_mode) {
|
||||
await prisma.acquisitionMode.upsert({
|
||||
where: { id: product.product.acquisition_mode?.id || 1 },
|
||||
update: {},
|
||||
create: {
|
||||
id: product.product.acquisition_mode?.id || 1,
|
||||
name: product.product.acquisition_mode?.name || 'Unknown',
|
||||
type: product.product.acquisition_mode?.type || 'ACQUISITION'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create Artists
|
||||
for (const product of data.products) {
|
||||
if (product.product?.artist) {
|
||||
await prisma.artist.upsert({
|
||||
where: { id: product.product.artist?.id || 0 },
|
||||
update: {},
|
||||
create: {
|
||||
id: product.product.artist?.id || 0,
|
||||
firstName: product.product.artist?.first_name,
|
||||
lastName: product.product.artist?.last_name || 'Unknown',
|
||||
dateBirth: product.product.artist?.date_birth,
|
||||
dateDeath: product.product.artist?.date_death,
|
||||
imageName: product.product.artist?.image_name,
|
||||
isPhare: product.product.artist?.is_phare || false,
|
||||
isCirca: product.product.artist?.is_circa || false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create Products
|
||||
const products = await Promise.all(
|
||||
data.products.map(item =>
|
||||
prisma.product.upsert({
|
||||
where: { code: item.product?.code || 'UNKNOWN' },
|
||||
update: {},
|
||||
create: {
|
||||
code: item.product?.code || 'UNKNOWN',
|
||||
title: item.product?.title || 'Unknown',
|
||||
acquisitionPrice: item.product?.acquisition_price || 0,
|
||||
dateFirstMeeting: item.product?.date_first_meeting,
|
||||
dateAcquisition: item.product?.date_acquisition,
|
||||
width: item.product?.width,
|
||||
height: item.product?.height,
|
||||
depth: item.product?.depth,
|
||||
diametre: item.product?.diametre,
|
||||
widthInch: item.product?.width_inch,
|
||||
heightInch: item.product?.height_inch,
|
||||
depthInch: item.product?.depth_inch,
|
||||
diametreInch: item.product?.diametre_inch,
|
||||
encadrement: item.product?.encadrement || false,
|
||||
collectionPerso: item.product?.collection_perso || false,
|
||||
venteDebout: item.product?.vente_debout || false,
|
||||
onWall: item.product?.on_wall || false,
|
||||
isMultiple: item.product?.is_multiple || false,
|
||||
wantedPrice: item.product?.wanted_price,
|
||||
wantedPriceMinimum: item.product?.wanted_price_minimum,
|
||||
comments: item.product?.comments,
|
||||
commentHistory: item.product?.comment_history,
|
||||
imageName: item.product?.image_name,
|
||||
year: item.product?.year,
|
||||
isCirca: item.product?.is_circa || false,
|
||||
artist: { connect: { id: item.product?.artist?.id || 0 } },
|
||||
category: { connect: { id: item.product?.category?.id || 0 } },
|
||||
type: { connect: { id: item.product?.type?.id || 1 } },
|
||||
state: { connect: { id: 1 } },
|
||||
acquisitionMode: { connect: { id: item.product?.acquisition_mode?.id || 1 } },
|
||||
client: { connect: { id: 2 } }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 6. Create Purchase
|
||||
const purchase = await prisma.purchase.create({
|
||||
data: {
|
||||
isGroupedPurchase: data.is_grouped_purchase || false,
|
||||
location: data.location,
|
||||
amountTotal: data.amount_total || 0,
|
||||
amountDecremented: data.amount_decremented || 0,
|
||||
fromDepotVente: data.from_depot_vente || false,
|
||||
customer: { connect: { id: data.customer?.id || 0 } },
|
||||
acquisitionMode: data.acquisition_mode?.id ?
|
||||
{ connect: { id: data.acquisition_mode.id } } :
|
||||
undefined,
|
||||
products: {
|
||||
create: data.products.map(item => ({
|
||||
product: { connect: { code: item.product?.code || 'UNKNOWN' } },
|
||||
acquisitionPrice: item.acquisition_price || 0,
|
||||
quantity: item.quantity || 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Import réussi pour l'entrée ${data.id}!`)
|
||||
}
|
||||
|
||||
console.log('Import complet réussi!')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'import:', error)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
seed()
|
||||
1499
backend/yarn.lock
Normal file
1499
backend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@ -0,0 +1,42 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- DB_PATH=/data/db.sqlite
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- REACT_APP_API_URL=http://localhost:3000
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- frontend
|
||||
|
||||
db:
|
||||
image: keinos/sqlite3
|
||||
volumes:
|
||||
- ./data:/data
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -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?
|
||||
50
frontend/README.md
Normal file
50
frontend/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 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/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
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'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.3",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.2",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
0
frontend/src/App.css
Normal file
0
frontend/src/App.css
Normal file
68
frontend/src/App.tsx
Normal file
68
frontend/src/App.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import Header from './components/Header'
|
||||
import Login from './pages/Login'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { authService, User } from './services/auth.service'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
export function PrivateRoute({ children }: { children: JSX.Element }) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
authService.getCurrentUser()
|
||||
.then(user => {
|
||||
setUser(user)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}, [])
|
||||
console.log(user)
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div> // Ou un spinner
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
// const [isLoading, setIsLoading] = useState(true)
|
||||
// const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
authService.getCurrentUser()
|
||||
.then(user => {
|
||||
// setUser(user)
|
||||
// setIsLoading(false)
|
||||
console.log(user)
|
||||
})
|
||||
}, [])
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@ -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 |
34
frontend/src/components/Header.tsx
Normal file
34
frontend/src/components/Header.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { MoonIcon, SunIcon } from "@radix-ui/react-icons"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
|
||||
export default function Header() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:border-border flex justify-center">
|
||||
<div className="container flex h-14 items-center">
|
||||
<div className="flex">
|
||||
<a href="/" className="flex items-center space-x-2">
|
||||
<span className="font-bold text-2xl">A</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between gap-2 md:justify-end">
|
||||
<nav className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
>
|
||||
{theme === "light" ? (
|
||||
<MoonIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<SunIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
128
frontend/src/components/Table.tsx
Normal file
128
frontend/src/components/Table.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
} from "@tanstack/react-table"
|
||||
import { useState } from "react"
|
||||
import { Input } from "./ui/input"
|
||||
import { Button } from "./ui/button"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter..."
|
||||
value={(table.getColumn("artist")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("artist")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/theme-provider.tsx
Normal file
64
frontend/src/components/theme-provider.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
return context
|
||||
}
|
||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
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-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary 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 }
|
||||
76
frontend/src/components/ui/card.tsx
Normal file
76
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
157
frontend/src/components/ui/select.tsx
Normal file
157
frontend/src/components/ui/select.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
120
frontend/src/components/ui/table.tsx
Normal file
120
frontend/src/components/ui/table.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
72
frontend/src/index.css
Normal file
72
frontend/src/index.css
Normal file
@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 10.2%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
150
frontend/src/pages/Dashboard.tsx
Normal file
150
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { DataTable } from "@/components/Table"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { purchaseService, Purchase } from "@/services/purchase.service"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { clientService, Client } from "@/services/client.service"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useState } from "react"
|
||||
import { Eye, ArrowUpDown } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const columns: ColumnDef<Purchase>[] = [
|
||||
{
|
||||
id: "artwork",
|
||||
accessorFn: (row) => row.products[0]?.product.title,
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting()} className="w-full justify-start">
|
||||
Œuvre <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const product = row.original.products[0]?.product
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div>{product?.title}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{product?.width}x{product?.height}cm • {product?.year?.split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "artist",
|
||||
accessorFn: (row) => `${row.products[0]?.product.artist.firstName || ''} ${row.products[0]?.product.artist.lastName}`,
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting()} className="w-full justify-start">
|
||||
Artiste <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "Date d'acquisition",
|
||||
accessorFn: (row) => row.products[0]?.product.title,
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting()} className="w-full justify-start">
|
||||
Date d'acquisition <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const product = row.original.products[0]?.product
|
||||
return (
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(product?.dateAcquisition).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
accessorFn: (row) => `${row.customer.contactFirstName} ${row.customer.contactLastName}`,
|
||||
header: ({ column }) => (
|
||||
<div className="text-left">Contact</div>
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
accessorKey: "amountTotal",
|
||||
header: ({ column }) => (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting()} className="w-full justify-end">
|
||||
Montant <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<div className="text-right">{`${getValue<number>().toLocaleString('fr-FR')} €`}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const imageName = row.original.products[0]?.product.imageName
|
||||
const clientUrl = row.original.products[0]?.product.client?.url
|
||||
const imageUrl = imageName && clientUrl ? `${clientUrl}media/cache/art_product_image/uploads/art/products/${imageName}` : null
|
||||
|
||||
return imageUrl ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.open(imageUrl, '_blank')}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export default function Purchases() {
|
||||
const { data: purchases = [], isLoading: purchasesLoading } = useQuery({
|
||||
queryKey: ['purchases'],
|
||||
queryFn: purchaseService.getAll
|
||||
})
|
||||
|
||||
const { data: clients = [], isLoading: clientsLoading } = useQuery({
|
||||
queryKey: ['clients'],
|
||||
queryFn: clientService.getAll
|
||||
})
|
||||
|
||||
const [selectedClient, setSelectedClient] = useState<string>('all')
|
||||
|
||||
const filteredPurchases = selectedClient === 'all'
|
||||
? purchases
|
||||
: purchases.filter(p => p.customer.id === parseInt(selectedClient))
|
||||
|
||||
if (purchasesLoading || clientsLoading) return <div>Chargement...</div>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<div className="mb-4">
|
||||
<Select value={selectedClient} onValueChange={setSelectedClient}>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Sélectionner un client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Clients</SelectLabel>
|
||||
<SelectItem value="all">Tous les clients</SelectItem>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id.toString()}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DataTable columns={columns} data={filteredPurchases} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
73
frontend/src/pages/Login.tsx
Normal file
73
frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardHeader, CardContent, CardFooter } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { authService } from "@/services/auth.service"
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await authService.login(formData)
|
||||
authService.saveToken(response.token)
|
||||
navigate('/')
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<h2 className="text-2xl font-bold text-center">Art</h2>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
disabled={isLoading}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
disabled={isLoading}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Connexion..." : "Se connecter"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
frontend/src/services/auth.service.ts
Normal file
79
frontend/src/services/auth.service.ts
Normal file
@ -0,0 +1,79 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
const response = await fetch(`${import.meta.env.VITE_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
saveToken(token: string) {
|
||||
localStorage.setItem('token', token);
|
||||
},
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
console.log('Token:', token);
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
console.log('Fetching /me...');
|
||||
const response = await fetch(`${import.meta.env.VITE_BASE_URL}/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('ME response not OK:', response.status);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('ME response:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('getCurrentUser error:', error);
|
||||
localStorage.removeItem('token');
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!localStorage.getItem('token');
|
||||
}
|
||||
};
|
||||
36
frontend/src/services/client.service.ts
Normal file
36
frontend/src/services/client.service.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export type Client = {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const clientService = {
|
||||
getAll: async (): Promise<Client[]> => {
|
||||
const response = await axios.get(`${import.meta.env.VITE_BASE_URL}/clients`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<Client> => {
|
||||
const response = await axios.get(`${import.meta.env.VITE_BASE_URL}/clients/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (client: Omit<Client, 'id'>): Promise<Client> => {
|
||||
const response = await axios.post(`${import.meta.env.VITE_BASE_URL}/clients`, client, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
37
frontend/src/services/purchase.service.ts
Normal file
37
frontend/src/services/purchase.service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export type Purchase = {
|
||||
id: number
|
||||
location: string
|
||||
amountTotal: number
|
||||
customer: {
|
||||
name: string
|
||||
contactFirstName: string
|
||||
contactLastName: string
|
||||
email: string
|
||||
}
|
||||
products: Array<{
|
||||
product: {
|
||||
code: string
|
||||
title: string
|
||||
artist: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
}
|
||||
acquisitionPrice: number
|
||||
}>
|
||||
}
|
||||
|
||||
export const purchaseService = {
|
||||
getAll: async (): Promise<Purchase[]> => {
|
||||
const response = await axios.get(`${import.meta.env.VITE_BASE_URL}/purchases`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
57
frontend/tailwind.config.js
Normal file
57
frontend/tailwind.config.js
Normal file
@ -0,0 +1,57 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
35
frontend/tsconfig.app.json
Normal file
35
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import path from "path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
2500
frontend/yarn.lock
Normal file
2500
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
15
nginx/nginx.conf
Normal file
15
nginx/nginx.conf
Normal file
@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Serve static files directly
|
||||
location / {
|
||||
root /path/to/frontend/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user