init project

This commit is contained in:
Arthur Barre 2024-12-13 18:38:00 +01:00
commit bc1c92a329
60 changed files with 9672 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
frontend/node_modules/
backend/node_modules/
frontend/.env

10
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

122
backend/src/index.js Normal file
View 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' })

View File

@ -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");

View File

@ -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");

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Client" ADD COLUMN "url" TEXT;

View 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"

View 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
}

View 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 })
}
})
}

View 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;

View 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 })
}
})
}

View 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 })
}
})
}

View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

42
docker-compose.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View 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
View File

68
frontend/src/App.tsx Normal file
View 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

View 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

View 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>
)
}

View 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>
)
}

View 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
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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
View 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;
}
}

View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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');
}
};

View 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
}
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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")],
}

View 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
View File

@ -0,0 +1,19 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

15
nginx/nginx.conf Normal file
View 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;
}
}