Add third laboratory work for Database Server Solutions class
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
# Серверні Рішення Баз Даних
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export DATABASE_URL="postgres://postgres:password@localhost:5432/car_shop"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n o.id,\n o.car_id,\n o.check_num,\n o.quantity,\n o.sold_at,\n c.price,\n c.name as car_name,\n b.name as car_brand,\n cc.name as centre_name,\n o.quantity * c.price as \"total!\"\n FROM orders o\n JOIN cars c ON o.car_id = c.id\n JOIN brand b ON c.brand_id = b.id\n JOIN carcentres cc ON c.car_centre_id = cc.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "car_id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "check_num",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "quantity",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "sold_at",
|
||||||
|
"type_info": "Date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "price",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "car_name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "car_brand",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "centre_name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "total!",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "18bc9a21e37b709a1c8e1c44ba0a4a39384259c4c95614f9cd5e0a5a2e664acc"
|
||||||
|
}
|
||||||
Generated
+62
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n c.id, c.name,\n b.name as brand,\n b.country_code as country,\n cc.name as center,\n c.price, c.quantity, c.description\n FROM cars c\n JOIN brand b ON c.brand_id = b.id\n JOIN carcentres cc ON c.car_centre_id = cc.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "brand",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "country",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "center",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "price",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "quantity",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "68249964aad245e918e19eedc27bb3d75e6420b7a781c996b923a11522f69974"
|
||||||
|
}
|
||||||
Generated
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, check_num, quantity, sold_at\n FROM orders\n WHERE car_id = $1\n ORDER BY sold_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "check_num",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "quantity",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "sold_at",
|
||||||
|
"type_info": "Date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "68883bb841693d9048b9edda2bd7985477ee9805ef5a936fc119e8e9fbfdf46b"
|
||||||
|
}
|
||||||
Generated
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n id as \"id!\",\n name as \"name!\",\n price as \"price!\",\n description\n FROM get_cars_cheaper_than_price($1)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name!",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "price!",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Numeric"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6f4fefe2d6ee0a7cacf8a254237ba5cd660dfeacb1546e2c70f01805683d23f5"
|
||||||
|
}
|
||||||
Generated
+20
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count_cars_cheaper_than_average() as \"c!\"",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "c!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": []
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9f3b5a02886911ff1ae8a737e82645e25ec069b1595007d63e4e9f5700dbf6cc"
|
||||||
|
}
|
||||||
Generated
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "CALL add_car_sale($1, $2, $3)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Varchar",
|
||||||
|
"Int4",
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b119502f9f01931da6dcfb2b961766ce87213fa0c7395e6cded9d06a50ee15c2"
|
||||||
|
}
|
||||||
Generated
+64
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT\n c.id, c.name,\n b.name as brand,\n b.country_code as country,\n cc.name as center,\n c.price, c.quantity, c.description\n FROM cars c\n JOIN brand b ON c.brand_id = b.id\n JOIN carcentres cc ON c.car_centre_id = cc.id\n WHERE c.id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "brand",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "country",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "center",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "price",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "quantity",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "description",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bee7ed2d30442907abf4fb9f156036bbc1bc5b7cd6b28889384298e47877276f"
|
||||||
|
}
|
||||||
Generated
+2935
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "car-shop"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.48", features = ["full"] }
|
||||||
|
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
utoipa-axum = "0.2"
|
||||||
|
utoipa = { version = "5.4", features = ["axum_extras", "chrono", "decimal"] }
|
||||||
|
utoipa-swagger-ui = { version = "9.0", features = ["axum"] }
|
||||||
|
|
||||||
|
rust_decimal = "1.39"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
sqlx = { version = "0.8", features = [
|
||||||
|
"runtime-tokio",
|
||||||
|
"tls-rustls",
|
||||||
|
"postgres",
|
||||||
|
"chrono",
|
||||||
|
"rust_decimal",
|
||||||
|
] }
|
||||||
|
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
listenfd = "1.0"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Car Shop
|
||||||
|
|
||||||
|
Laboratory work number 3, which includes:
|
||||||
|
- A PostgreSQL database with functions and procedures.
|
||||||
|
- A Rust back-end that uses sqlx for the database, Axum for the web and Utoipa for OpenApi spec.
|
||||||
|
- A Front-end for better UX than Swagger UI, which I'll never tell you where I got it from.
|
||||||
|
|
||||||
|
## To start the project from this directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Init the database
|
||||||
|
docker run --name srbd3 \
|
||||||
|
-e POSTGRES_PASSWORD=password \
|
||||||
|
-e PGDATA=/var/lib/postgresql/pgdata \
|
||||||
|
-p 5432:5432 -d postgres
|
||||||
|
sleep 3; # wait a bit if running commands in a batch
|
||||||
|
docker exec srbd3 psql -U postgres -c "CREATE DATABASE car_shop;"
|
||||||
|
docker cp sql/init.sql srbd3:/var/lib/postgresql/pgdata/
|
||||||
|
docker exec srbd3 psql -U postgres -d car_shop -f /var/lib/postgresql/pgdata/init.sql
|
||||||
|
|
||||||
|
# Get environment variables (optional)
|
||||||
|
source .envrc
|
||||||
|
|
||||||
|
# Compile and execute the backend
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# Bootstrap and start the website
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Website is at <http://localhost:3002>
|
||||||
|
- Swagger is at <http://localhost:3000/swagger-ui>
|
||||||
|
- OpenAPI spec is at <http://localhost:3000/apidoc/openapi.json>
|
||||||
|
|
||||||
|
## Showcase
|
||||||
|
### Swagger UI
|
||||||
|

|
||||||
|
|
||||||
|
### Website Dashboard
|
||||||
|

|
||||||
|
|
||||||
|
### Inventory
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Sales
|
||||||
|

|
||||||
|

|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Inventory from './pages/Inventory';
|
||||||
|
import Sales from './pages/Sales';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
|
<Route path="/sales" element={<Sales />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Car, ShoppingCart, BarChart3, Menu, X } from 'lucide-react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'Dashboard', path: '/', icon: BarChart3 },
|
||||||
|
{ name: 'Inventory', path: '/inventory', icon: Car },
|
||||||
|
{ name: 'Sales & Orders', path: '/sales', icon: ShoppingCart },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50">
|
||||||
|
{/* Mobile Sidebar Backdrop */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-20 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-30 w-64 transform bg-white shadow-xl transition-transform duration-300 ease-in-out lg:static lg:translate-x-0 ${
|
||||||
|
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center justify-center border-b border-gray-100 px-6">
|
||||||
|
<span className="text-xl font-bold text-indigo-600">CarShop Panel</span>
|
||||||
|
</div>
|
||||||
|
<nav className="mt-6 px-4 space-y-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = location.pathname === item.path;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
className={`group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-indigo-50 text-indigo-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`mr-3 h-5 w-5 ${isActive ? 'text-indigo-600' : 'text-gray-400 group-hover:text-gray-500'}`} />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<header className="flex h-16 items-center justify-between bg-white px-6 shadow-sm lg:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
className="rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<span className="text-lg font-semibold text-gray-900">CarShop</span>
|
||||||
|
<div className="w-6" /> {/* Spacer for centering */}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: LucideIcon;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatCard: React.FC<StatCardProps> = ({ title, value, icon: Icon, description, color = "indigo" }) => {
|
||||||
|
const colorClasses = {
|
||||||
|
indigo: "bg-indigo-50 text-indigo-600",
|
||||||
|
green: "bg-green-50 text-green-600",
|
||||||
|
blue: "bg-blue-50 text-blue-600",
|
||||||
|
}[color] || "bg-gray-50 text-gray-600";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 transition-all hover:shadow-md">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${colorClasses}`}>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">{title}</h3>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-gray-900">{value}</div>
|
||||||
|
{description && <p className="mt-1 text-xs text-gray-400">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatCard;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const API_BASE_URL = 'http://127.0.0.1:3000/api';
|
||||||
|
|
||||||
|
export const CURRENCY_FORMAT = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CarShop Dashboard</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.bunny.net/css?family=inter:300,400,500,600,700" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react": "https://cdnjs.com/react@^19.2.0",
|
||||||
|
"react/": "https://cdnjs.com/react@^19.2.0/",
|
||||||
|
"react-router-dom": "https://cdnjs.com/react-router-dom@^7.9.6",
|
||||||
|
"lucide-react": "https://cdnjs.com/lucide-react@^0.555.0",
|
||||||
|
"react-dom/": "https://cdnjs.com/react-dom@^19.2.0/",
|
||||||
|
"recharts": "https://cdnjs.com/recharts@^3.5.0",
|
||||||
|
"d3-format": "https://cdnjs.com/d3-format@^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-gray-50 text-slate-900 antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element to mount to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "CarShop Dashboard",
|
||||||
|
"description": "A RESTful client for the CarShop API demonstrating stored procedures, functions, and error handling.",
|
||||||
|
"requestFramePermissions": []
|
||||||
|
}
|
||||||
+3274
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "carshop-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"recharts": "^3.5.0",
|
||||||
|
"d3-format": "^3.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||||
|
import { format as d3Format } from 'd3-format';
|
||||||
|
import { CarFullSales } from '../types';
|
||||||
|
import { carService } from '../services/api';
|
||||||
|
import StatCard from '../components/StatCard';
|
||||||
|
import { TrendingDown, CarFront, Activity, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const [cars, setCars] = useState<CarFullSales[]>([]);
|
||||||
|
const [cheapCount, setCheapCount] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const carsData = await carService.getAllCars();
|
||||||
|
setCars(carsData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statsData = await carService.getCheapCarsCount();
|
||||||
|
setCheapCount(statsData.count);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not fetch stats:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to fetch dashboard data", err);
|
||||||
|
setError(err.message || "Failed to load dashboard data");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const chartData = cars.map(car => ({
|
||||||
|
name: car.name,
|
||||||
|
price: parseFloat(car.price),
|
||||||
|
brand: car.brand
|
||||||
|
})).sort((a, b) => b.price - a.price);
|
||||||
|
|
||||||
|
const formatCurrencyD3 = d3Format("$,.0f");
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex h-96 items-center justify-center text-gray-400">Loading Dashboard...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Executive Overview</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Real-time metrics from the database.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-50 p-4 border border-red-200">
|
||||||
|
<div className="flex">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">Connection Error</h3>
|
||||||
|
<div className="mt-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Total Inventory"
|
||||||
|
value={cars.reduce((acc, c) => acc + c.quantity, 0)}
|
||||||
|
icon={CarFront}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Avg. Price Comparison"
|
||||||
|
value={cheapCount !== null ? cheapCount : '-'}
|
||||||
|
description="Cars cheaper than average (Scalar Func)"
|
||||||
|
icon={TrendingDown}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Models"
|
||||||
|
value={cars.length}
|
||||||
|
icon={Activity}
|
||||||
|
color="indigo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5">
|
||||||
|
<h3 className="mb-6 text-base font-semibold leading-6 text-gray-900">Price Distribution by Model</h3>
|
||||||
|
{cars.length > 0 ? (
|
||||||
|
<div className="h-[400px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
|
||||||
|
<XAxis dataKey="name" stroke="#9CA3AF" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(val) => `$${val / 1000}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: '#F3F4F6' }}
|
||||||
|
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
||||||
|
formatter={(value: number) => [formatCurrencyD3(value), 'Price']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="price" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={index % 2 === 0 ? '#4F46E5' : '#818CF8'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-400 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
{error ? "No data available due to error." : "No inventory data found."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { carService, salesService } from '../services/api';
|
||||||
|
import { CarFullSales, CheapCarRow } from '../types';
|
||||||
|
import { CURRENCY_FORMAT } from '../constants';
|
||||||
|
import { Filter, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const Inventory: React.FC = () => {
|
||||||
|
const [cars, setCars] = useState<CarFullSales[]>([]);
|
||||||
|
const [filteredCars, setFilteredCars] = useState<CheapCarRow[] | null>(null);
|
||||||
|
const [priceThreshold, setPriceThreshold] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<'all' | 'filtered'>('all');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAllCars();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAllCars = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [carsData, salesData] = await Promise.all([
|
||||||
|
carService.getAllCars(),
|
||||||
|
salesService.getAllSales()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enrichedCars = carsData.map(car => {
|
||||||
|
const carSales = salesData
|
||||||
|
.filter(sale => sale.car_id === car.id)
|
||||||
|
.map(sale => ({
|
||||||
|
id: sale.id,
|
||||||
|
check_num: sale.check_num,
|
||||||
|
quantity: sale.quantity,
|
||||||
|
sold_at: sale.sold_at
|
||||||
|
}))
|
||||||
|
.sort((a, b) => new Date(b.sold_at).getTime() - new Date(a.sold_at).getTime());
|
||||||
|
|
||||||
|
return { ...car, sales: carSales };
|
||||||
|
});
|
||||||
|
|
||||||
|
setCars(enrichedCars);
|
||||||
|
setViewMode('all');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to load inventory");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilter = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!priceThreshold) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await carService.getCarsCheaperThan(parseFloat(priceThreshold));
|
||||||
|
setFilteredCars(data);
|
||||||
|
setViewMode('filtered');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to filter cars");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Vehicle Inventory</h1>
|
||||||
|
<p className="text-sm text-gray-500">Manage stock and view detailed car information.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Function Demo Section */}
|
||||||
|
<form onSubmit={handleFilter} className="flex items-center gap-2 rounded-lg bg-white p-2 shadow-sm ring-1 ring-gray-200">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
|
<span className="text-gray-500 sm:text-sm">$</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={priceThreshold}
|
||||||
|
onChange={(e) => setPriceThreshold(e.target.value)}
|
||||||
|
placeholder="Max Price"
|
||||||
|
className="block w-32 rounded-md border-0 py-1.5 pl-7 pr-2 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1 rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
{viewMode === 'filtered' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadAllCars}
|
||||||
|
className="ml-2 text-sm text-gray-500 hover:text-gray-900 underline"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 flex items-center">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400 mr-2" />
|
||||||
|
<span className="text-sm text-red-700">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{loading ? (
|
||||||
|
[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="h-64 animate-pulse rounded-xl bg-gray-200" />
|
||||||
|
))
|
||||||
|
) : viewMode === 'all' ? (
|
||||||
|
cars.map((car) => (
|
||||||
|
<CarCard key={car.id} car={car} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
filteredCars?.map((car) => (
|
||||||
|
<SimpleCarCard key={car.id} car={car} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && viewMode === 'all' && cars.length === 0 && !error && (
|
||||||
|
<div className="col-span-full py-12 text-center text-gray-500">
|
||||||
|
Inventory is empty.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && viewMode === 'filtered' && filteredCars?.length === 0 && !error && (
|
||||||
|
<div className="col-span-full py-12 text-center text-gray-500">
|
||||||
|
No cars found cheaper than the specified price.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CarCard: React.FC<{ car: CarFullSales }> = ({ car }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-between overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 transition-hover hover:shadow-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||||
|
{car.brand}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">{CURRENCY_FORMAT.format(parseFloat(car.price))}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-xl font-semibold text-gray-900">{car.name}</h3>
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{car.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center gap-x-4 text-xs leading-5 text-gray-500">
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
|
<span className="font-semibold text-gray-900">Stock:</span> {car.quantity}
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-1 rounded-full bg-gray-300" />
|
||||||
|
<div>{car.center}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 px-6 py-4">
|
||||||
|
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Sales History</div>
|
||||||
|
{car.sales && car.sales.length > 0 ? (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{car.sales.slice(0, 3).map(sale => (
|
||||||
|
<li key={sale.id} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">{sale.sold_at}</span>
|
||||||
|
<span className="font-medium text-gray-900">Qty: {sale.quantity}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{car.sales.length > 3 && <li className="text-xs text-center text-gray-400">+{car.sales.length - 3} more</li>}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm italic text-gray-400">No sales recorded yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SimpleCarCard: React.FC<{ car: CheapCarRow }> = ({ car }) => (
|
||||||
|
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 hover:ring-indigo-500/50">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{car.name}</h3>
|
||||||
|
<span className="font-bold text-green-600">{CURRENCY_FORMAT.format(parseFloat(car.price))}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">{car.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Inventory;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { salesService } from '../services/api';
|
||||||
|
import { OrderFull, AddSaleRequest } from '../types';
|
||||||
|
import { CURRENCY_FORMAT } from '../constants';
|
||||||
|
import { PlusCircle, Search, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const Sales: React.FC = () => {
|
||||||
|
const [sales, setSales] = useState<OrderFull[]>([]);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const loadSales = async () => {
|
||||||
|
try {
|
||||||
|
const data = await salesService.getAllSales();
|
||||||
|
setSales(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSales();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Sales Transactions</h1>
|
||||||
|
<p className="text-sm text-gray-500">View history and record new sales.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="flex items-center gap-2 rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-5 w-5" />
|
||||||
|
New Sale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sales Table */}
|
||||||
|
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Date</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Check #</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Car Info</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Centre</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{sales.map((order) => (
|
||||||
|
<tr key={order.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">{order.sold_at}</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm font-mono text-gray-500">{order.check_num}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{order.car_name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{order.car_brand} • Qty: {order.quantity}</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">{order.centre_name}</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-bold text-gray-900">
|
||||||
|
{CURRENCY_FORMAT.format(parseFloat(order.total))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{sales.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">No sales records found.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<AddSaleModal
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
loadSales();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddSaleModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddSaleModal: React.FC<AddSaleModalProps> = ({ onClose, onSuccess }) => {
|
||||||
|
const [formData, setFormData] = useState<AddSaleRequest>({
|
||||||
|
car_name: '',
|
||||||
|
quantity: 1,
|
||||||
|
check_num: null
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await salesService.addSale(formData);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err: any) {
|
||||||
|
let msg = err.message || "An error occurred";
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorState = () => {
|
||||||
|
if (!error) return null;
|
||||||
|
const lowerMsg = error.toLowerCase();
|
||||||
|
|
||||||
|
const isConnection = lowerMsg.includes('connect') || lowerMsg.includes('cors') || lowerMsg.includes('network');
|
||||||
|
const isNotFound = lowerMsg.includes('not found') || lowerMsg.includes('404') || lowerMsg.includes('не знайдено');
|
||||||
|
|
||||||
|
return { isConnection, isNotFound };
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorState = getErrorState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||||
|
<div className="bg-gray-50 px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Record New Sale</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Executes stored procedure <code>add_car_sale</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
|
{error && errorState && (
|
||||||
|
<div className={`rounded-md p-4 ${errorState.isConnection ? 'bg-amber-50' : 'bg-red-50'}`}>
|
||||||
|
<div className="flex">
|
||||||
|
<AlertCircle className={`h-5 w-5 ${errorState.isConnection ? 'text-amber-400' : 'text-red-400'}`} />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className={`text-sm font-medium ${errorState.isConnection ? 'text-amber-800' : 'text-red-800'}`}>
|
||||||
|
{errorState.isConnection
|
||||||
|
? "Connection Error"
|
||||||
|
: errorState.isNotFound
|
||||||
|
? "Car Not Found"
|
||||||
|
: "Database / Logic Error"}
|
||||||
|
</h3>
|
||||||
|
<div className={`mt-2 text-sm font-mono text-xs whitespace-pre-wrap ${errorState.isConnection ? 'text-amber-700' : 'text-red-700'}`}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Car Name (Partial Search)</label>
|
||||||
|
<div className="mt-1 relative">
|
||||||
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="block w-full rounded-md border-0 py-2 pl-9 pr-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm"
|
||||||
|
placeholder="e.g. Corolla"
|
||||||
|
value={formData.car_name}
|
||||||
|
onChange={e => setFormData({ ...formData, car_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Finds first matching car using SQL <code>ILIKE</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Quantity</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className="mt-1 block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm"
|
||||||
|
value={formData.quantity || 1}
|
||||||
|
onChange={e => setFormData({ ...formData, quantity: parseInt(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Check # (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="mt-1 block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm"
|
||||||
|
placeholder="Auto"
|
||||||
|
value={formData.check_num || ''}
|
||||||
|
onChange={e => setFormData({ ...formData, check_num: e.target.value ? parseInt(e.target.value) : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="inline-flex justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Processing...' : 'Confirm Sale'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sales;
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { API_BASE_URL } from '../constants';
|
||||||
|
import { AddSaleRequest, CarFullSales, CheapCarRow, OrderFull, StatsResponse } from '../types';
|
||||||
|
|
||||||
|
class AppError extends Error {
|
||||||
|
constructor(public message: string, public originalError?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const url = `${API_BASE_URL}${endpoint}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
throw new AppError(json.message || json.error || `Server Error: ${response.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (text && !text.startsWith('{')) {
|
||||||
|
throw new AppError(text);
|
||||||
|
}
|
||||||
|
throw new AppError(`HTTP Error ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : {} as T;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof TypeError && (error.message === 'Failed to fetch' || error.message.includes('NetworkError'))) {
|
||||||
|
console.error(`Network Error calling ${url}.`);
|
||||||
|
throw new AppError(
|
||||||
|
"Could not connect to the server."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new AppError(error.message || "An unexpected error occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const carService = {
|
||||||
|
getAllCars: async (): Promise<CarFullSales[]> => {
|
||||||
|
return request<CarFullSales[]>('/cars');
|
||||||
|
},
|
||||||
|
|
||||||
|
getCheapCarsCount: async (): Promise<StatsResponse> => {
|
||||||
|
return request<StatsResponse>('/cars/cheaper-than-avg');
|
||||||
|
},
|
||||||
|
|
||||||
|
getCarsCheaperThan: async (price: number): Promise<CheapCarRow[]> => {
|
||||||
|
return request<CheapCarRow[]>(`/cars/cheaper-than/${price}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCarDetails: async (id: number): Promise<CarFullSales> => {
|
||||||
|
return request<CarFullSales>(`/cars/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const salesService = {
|
||||||
|
getAllSales: async (): Promise<OrderFull[]> => {
|
||||||
|
return request<OrderFull[]>('/sales');
|
||||||
|
},
|
||||||
|
|
||||||
|
addSale: async (data: AddSaleRequest): Promise<void> => {
|
||||||
|
return request<void>('/sales', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Schema definitions based on OpenAPI spec
|
||||||
|
|
||||||
|
export interface CarSale {
|
||||||
|
id: number;
|
||||||
|
check_num: number;
|
||||||
|
quantity: number;
|
||||||
|
sold_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarFull {
|
||||||
|
id: number;
|
||||||
|
brand: string;
|
||||||
|
name: string;
|
||||||
|
center: string;
|
||||||
|
country: string | null;
|
||||||
|
description: string | null;
|
||||||
|
price: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarFullSales extends CarFull {
|
||||||
|
sales?: CarSale[]; // the list endpoint might not return it
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheapCarRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderFull {
|
||||||
|
id: number;
|
||||||
|
check_num: number;
|
||||||
|
centre_name: string;
|
||||||
|
car_id: number;
|
||||||
|
car_brand: string;
|
||||||
|
car_name: string;
|
||||||
|
price: string;
|
||||||
|
quantity: number;
|
||||||
|
total: string;
|
||||||
|
sold_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsResponse {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddSaleRequest {
|
||||||
|
car_name: string;
|
||||||
|
check_num?: number | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
port: 3002,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,133 @@
|
|||||||
|
CREATE TABLE Brand (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
country_code VARCHAR(10),
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE CarCentres (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(150) NOT NULL,
|
||||||
|
address VARCHAR(255),
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Cars (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
brand_id INT NOT NULL REFERENCES Brand(id),
|
||||||
|
car_centre_id INT NOT NULL REFERENCES CarCentres(id),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
price NUMERIC(10, 2) NOT NULL CHECK (price > 0),
|
||||||
|
quantity INT NOT NULL CHECK (quantity >= 0),
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE Orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
car_id INT NOT NULL REFERENCES Cars(id),
|
||||||
|
check_num INT NOT NULL,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0),
|
||||||
|
sold_at DATE NOT NULL DEFAULT CURRENT_DATE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO Brand (name, country_code, description) VALUES
|
||||||
|
('Toyota', 'JP', 'Japanese automotive manufacturer'),
|
||||||
|
('Volkswagen', 'DE', 'German multinational automotive manufacturing company'),
|
||||||
|
('Ford', 'US', 'American multinational automaker'),
|
||||||
|
('BMW', 'DE', 'German multinational company which produces automobiles and motorcycles'),
|
||||||
|
('Mercedes-Benz', 'DE', 'German global automobile marque');
|
||||||
|
|
||||||
|
INSERT INTO CarCentres (name, address, description) VALUES
|
||||||
|
('Prestige Auto', 'Київ, вул. Центральна, 1', 'Офіційний дилер європейських брендів'),
|
||||||
|
('Global Cars', 'Львів, просп. Свободи, 25', 'Мультибрендовий автосалон'),
|
||||||
|
('Auto World', 'Одеса, вул. Морська, 15', 'Продаж нових та вживаних авто');
|
||||||
|
|
||||||
|
INSERT INTO Cars (brand_id, car_centre_id, name, price, quantity, description) VALUES
|
||||||
|
(1, 1, 'Corolla', 25000.00, 10, 'Reliable and efficient sedan'),
|
||||||
|
(2, 1, 'Golf', 28000.00, 8, 'Popular compact car'),
|
||||||
|
(4, 1, 'X5', 75000.00, 5, 'Luxury mid-size SUV'),
|
||||||
|
(1, 2, 'Camry', 30000.00, 12, 'Comfortable and spacious sedan'),
|
||||||
|
(3, 2, 'Mustang', 55000.00, 3, 'Iconic American muscle car'),
|
||||||
|
(5, 3, 'C-Class', 45000.00, 7, 'Compact executive car'),
|
||||||
|
(2, 3, 'Passat', 32000.00, 9, 'Mid-size family car'),
|
||||||
|
(4, 2, '3 Series', 48000.00, 6, 'Sporty and dynamic sedan'),
|
||||||
|
(1, 3, 'RAV4', 35000.00, 15, 'Versatile and popular SUV'),
|
||||||
|
(5, 1, 'E-Class', 65000.00, 4, 'Executive luxury sedan'),
|
||||||
|
(4, 1, 'X5', 82000.00, 2, 'Luxury mid-size SUV with M-package'),
|
||||||
|
(1, 2, 'Camry Hybrid', 32000.00, 7, 'Fuel-efficient hybrid sedan');
|
||||||
|
|
||||||
|
INSERT INTO Orders (car_id, check_num, quantity, sold_at) VALUES
|
||||||
|
(3, 1001, 1, '2025-10-22'), -- BMW X5
|
||||||
|
(5, 1002, 1, '2025-10-22'), -- Ford Mustang
|
||||||
|
(1, 1003, 2, '2025-10-23'), -- Toyota Corolla
|
||||||
|
(9, 1004, 1, '2025-10-23'), -- Toyota RAV4
|
||||||
|
(2, 1005, 1, '2025-10-24'); -- VW Golf
|
||||||
|
|
||||||
|
|
||||||
|
-- 1. Виведення інформації з таблиць. Бажано зробить виведення з пов'язаних таблиць на одній сторінці (наприклад, докладна інформація про один з товарів без дублювання та вся інформація про його продажі).
|
||||||
|
-- 4. Оброблення виключної ситуації у застосунку, згенерованої на боці сервера бази даних у підпрограмах користувача (бажано ініціювати користувацьке виключення та обробити його в клієнтському додатку).
|
||||||
|
/* В коді застосунку */
|
||||||
|
|
||||||
|
-- 2. Виконання процедури з передачею їй параметрів із створеного додатку (наприклад, додавання інформацію в таблицю, яка виводиться).
|
||||||
|
CREATE OR REPLACE PROCEDURE add_car_sale(
|
||||||
|
p_car_name VARCHAR,
|
||||||
|
p_check_num INT DEFAULT NULL,
|
||||||
|
p_quantity INT DEFAULT 1
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_car_id INT;
|
||||||
|
v_full_car_name VARCHAR;
|
||||||
|
v_check_num INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT id, name
|
||||||
|
INTO v_car_id, v_full_car_name
|
||||||
|
FROM Cars
|
||||||
|
WHERE name ILIKE '%' || p_car_name || '%'
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Не знайдено жодного автомобіля, назва котрого містить "%".', p_car_name
|
||||||
|
USING ERRCODE = 'P0002'; -- no_data_found
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_check_num IS NULL THEN
|
||||||
|
SELECT COALESCE(MAX(check_num), 0) + 1 INTO v_check_num FROM Orders;
|
||||||
|
ELSE
|
||||||
|
v_check_num := p_check_num;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO Orders (car_id, check_num, quantity, sold_at)
|
||||||
|
VALUES (v_car_id, v_check_num, p_quantity, CURRENT_DATE);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Продаж успішно додано для автомобіля: "%". Номер чеку: %', v_full_car_name, v_check_num;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
-- 3. Виконання скалярної та табличної функцій (наприклад, підрахувати кількість товарів у заданому відділі).
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION count_cars_cheaper_than_average()
|
||||||
|
RETURNS INT
|
||||||
|
BEGIN ATOMIC
|
||||||
|
SELECT COUNT(*)::INT
|
||||||
|
FROM Cars
|
||||||
|
WHERE price < (SELECT AVG(price) FROM Cars);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_cars_cheaper_than_price(
|
||||||
|
p_price NUMERIC
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id INT,
|
||||||
|
name VARCHAR,
|
||||||
|
price NUMERIC,
|
||||||
|
description TEXT
|
||||||
|
)
|
||||||
|
BEGIN ATOMIC
|
||||||
|
SELECT id, name, price, description
|
||||||
|
FROM Cars
|
||||||
|
WHERE price < p_price;
|
||||||
|
END;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{PgPool, Pool, Postgres};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||||
|
|
||||||
|
use crate::{CAR_TAG, Result, error::Error};
|
||||||
|
|
||||||
|
pub fn router() -> OpenApiRouter<Pool<Postgres>> {
|
||||||
|
OpenApiRouter::new()
|
||||||
|
.routes(routes!(get_cars))
|
||||||
|
.routes(routes!(get_car_details))
|
||||||
|
.routes(routes!(get_cars_cheaper_than))
|
||||||
|
.routes(routes!(get_cars_cheaper_than_avg))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct CarFull {
|
||||||
|
pub id: i32,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub brand: String,
|
||||||
|
pub name: String,
|
||||||
|
pub center: String,
|
||||||
|
pub price: Decimal,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List information of all cars
|
||||||
|
#[utoipa::path(get, path = "", responses((status = OK, body = Vec<CarFullSales>)), tag = CAR_TAG)]
|
||||||
|
pub async fn get_cars(State(pool): State<PgPool>) -> Result<Json<Vec<CarFull>>> {
|
||||||
|
let cars = sqlx::query_as!(
|
||||||
|
CarFull,
|
||||||
|
"SELECT
|
||||||
|
c.id, c.name,
|
||||||
|
b.name as brand,
|
||||||
|
b.country_code as country,
|
||||||
|
cc.name as center,
|
||||||
|
c.price, c.quantity, c.description
|
||||||
|
FROM cars c
|
||||||
|
JOIN brand b ON c.brand_id = b.id
|
||||||
|
JOIN carcentres cc ON c.car_centre_id = cc.id"
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(cars))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct CarFullSales {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub car_full: CarFull,
|
||||||
|
pub sales: Vec<CarSale>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct CarSale {
|
||||||
|
pub id: i32,
|
||||||
|
pub check_num: i32,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub sold_at: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get detailed info about a car and it's sales history
|
||||||
|
#[utoipa::path(get, path = "/{id}", params(("id" = i32, Path, description = "Car ID")),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Car details found", body = CarFullSales),
|
||||||
|
(status = NOT_FOUND, description = "Car not found")
|
||||||
|
),
|
||||||
|
tag = CAR_TAG
|
||||||
|
)]
|
||||||
|
pub async fn get_car_details(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<CarFullSales>> {
|
||||||
|
let car_full = sqlx::query_as!(
|
||||||
|
CarFull,
|
||||||
|
"SELECT
|
||||||
|
c.id, c.name,
|
||||||
|
b.name as brand,
|
||||||
|
b.country_code as country,
|
||||||
|
cc.name as center,
|
||||||
|
c.price, c.quantity, c.description
|
||||||
|
FROM cars c
|
||||||
|
JOIN brand b ON c.brand_id = b.id
|
||||||
|
JOIN carcentres cc ON c.car_centre_id = cc.id
|
||||||
|
WHERE c.id = $1",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound(format!("Car with id {id} not found")))?;
|
||||||
|
|
||||||
|
let sales = sqlx::query_as!(
|
||||||
|
CarSale,
|
||||||
|
"SELECT id, check_num, quantity, sold_at
|
||||||
|
FROM orders
|
||||||
|
WHERE car_id = $1
|
||||||
|
ORDER BY sold_at DESC",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(CarFullSales { car_full, sales }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct CheapCarRow {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub price: Decimal,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List cars cheaper than price
|
||||||
|
#[utoipa::path(get, path = "/cheaper-than/{price}",
|
||||||
|
params(("price" = f64, Path, description = "Price threshold")),
|
||||||
|
responses((status = OK, body = Vec<CheapCarRow>)),
|
||||||
|
tag = CAR_TAG
|
||||||
|
)]
|
||||||
|
pub async fn get_cars_cheaper_than(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(price): Path<Decimal>,
|
||||||
|
) -> Result<Json<Vec<CheapCarRow>>> {
|
||||||
|
let cars = sqlx::query_as!(
|
||||||
|
CheapCarRow,
|
||||||
|
r#"SELECT
|
||||||
|
id as "id!",
|
||||||
|
name as "name!",
|
||||||
|
price as "price!",
|
||||||
|
description
|
||||||
|
FROM get_cars_cheaper_than_price($1)"#,
|
||||||
|
price
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(cars))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct StatsResponse {
|
||||||
|
pub count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count cars cheaper than average car price
|
||||||
|
#[utoipa::path(get, path = "/cheaper-than-avg", responses((status = 200, body = StatsResponse)), tag = CAR_TAG)]
|
||||||
|
pub async fn get_cars_cheaper_than_avg(State(pool): State<PgPool>) -> Result<Json<StatsResponse>> {
|
||||||
|
let count: i32 = sqlx::query_scalar!(r#"SELECT count_cars_cheaper_than_average() as "c!""#)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(StatsResponse { count }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Custom application error type
|
||||||
|
pub enum Error {
|
||||||
|
/// Standard database error
|
||||||
|
Sqlx(sqlx::Error),
|
||||||
|
/// Resource not found
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for Error {
|
||||||
|
fn from(err: sqlx::Error) -> Self {
|
||||||
|
if let sqlx::Error::Database(db_err) = &err
|
||||||
|
&& let Some(code) = db_err.code()
|
||||||
|
&& code.as_ref() == "P0002"
|
||||||
|
{
|
||||||
|
Self::NotFound(db_err.message().to_string())
|
||||||
|
} else {
|
||||||
|
Self::Sqlx(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for Error {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
Self::Sqlx(err) => {
|
||||||
|
match err {
|
||||||
|
sqlx::Error::Database(db_err) => {
|
||||||
|
let msg = db_err.message().to_string();
|
||||||
|
(StatusCode::BAD_REQUEST, msg)
|
||||||
|
}
|
||||||
|
sqlx::Error::RowNotFound => {
|
||||||
|
(StatusCode::NOT_FOUND, "Record not found".to_string())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Log the internal error for admin, don't show details to user
|
||||||
|
tracing::error!("Internal SQL error: {:?}", err);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#![allow(clippy::needless_for_each)] // OpenApi macro
|
||||||
|
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
use listenfd::ListenFd;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
use utoipa_axum::router::OpenApiRouter;
|
||||||
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
mod cars;
|
||||||
|
mod error;
|
||||||
|
mod sales;
|
||||||
|
|
||||||
|
pub type Result<T, E = error::Error> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
const CAR_TAG: &str = "Cars";
|
||||||
|
const SALES_TAG: &str = "Sales";
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(tags(
|
||||||
|
(name = CAR_TAG, description = "Car API Endpoints"),
|
||||||
|
(name = SALES_TAG, description = "Sale API Endpoints"),
|
||||||
|
))]
|
||||||
|
pub struct ApiDoc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Logging
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "debug".into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Database
|
||||||
|
let db_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "postgres://postgres:password@localhost:5432/car_shop".into());
|
||||||
|
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.acquire_timeout(Duration::from_secs(3))
|
||||||
|
.connect(&db_url)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
tracing::info!("Connected to database at {}", db_url);
|
||||||
|
|
||||||
|
// OpenAPI and Router
|
||||||
|
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
|
||||||
|
.nest("/api/cars", cars::router())
|
||||||
|
.nest("/api/sales", sales::router())
|
||||||
|
.with_state(pool)
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin("http://localhost:3002".parse::<HeaderValue>().unwrap())
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.split_for_parts();
|
||||||
|
let router = router.merge(SwaggerUi::new("/swagger-ui").url("/apidoc/openapi.json", api));
|
||||||
|
|
||||||
|
// Run Server with support for systemfd/cargo-watch
|
||||||
|
let mut listenfd = ListenFd::from_env();
|
||||||
|
let listener = match listenfd.take_tcp_listener(0).unwrap() {
|
||||||
|
Some(listener) => {
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
TcpListener::from_std(listener).unwrap()
|
||||||
|
}
|
||||||
|
None => TcpListener::bind("127.0.0.1:3000").await.unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tracing::info!("Listening on {addr}");
|
||||||
|
tracing::info!("Swagger UI available at http://{addr}/swagger-ui");
|
||||||
|
|
||||||
|
axum::serve(listener, router).await.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// Custom extractor, grabs a connection from the pool once for the whole handler.
|
||||||
|
struct DatabaseConnection(sqlx::pool::PoolConnection<sqlx::Postgres>);
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for DatabaseConnection
|
||||||
|
where
|
||||||
|
PgPool: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = (StatusCode, String);
|
||||||
|
|
||||||
|
async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
let pool = PgPool::from_ref(state);
|
||||||
|
|
||||||
|
let conn = pool.acquire().await.map_err(internal_error)?;
|
||||||
|
|
||||||
|
Ok(Self(conn))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map any error into a `500 Internal Server Error` response.
|
||||||
|
fn internal_error<E: ToString>(err: E) -> (StatusCode, String) {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{PgPool, Pool, Postgres};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||||
|
|
||||||
|
use crate::{Result, SALES_TAG};
|
||||||
|
|
||||||
|
pub fn router() -> OpenApiRouter<Pool<Postgres>> {
|
||||||
|
OpenApiRouter::new().routes(routes!(get_sales, add_sale))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
pub struct OrderFull {
|
||||||
|
pub id: i32,
|
||||||
|
pub check_num: i32,
|
||||||
|
pub centre_name: String,
|
||||||
|
pub car_id: i32,
|
||||||
|
pub car_brand: String,
|
||||||
|
pub car_name: String,
|
||||||
|
pub price: Decimal,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub total: Decimal,
|
||||||
|
pub sold_at: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get detailed info about sales
|
||||||
|
#[utoipa::path(get, path = "", responses((status = OK, body = Vec<OrderFull>)), tag = SALES_TAG)]
|
||||||
|
pub async fn get_sales(State(pool): State<PgPool>) -> Result<Json<Vec<OrderFull>>> {
|
||||||
|
let orders = sqlx::query_as!(
|
||||||
|
OrderFull,
|
||||||
|
r#"SELECT
|
||||||
|
o.id,
|
||||||
|
o.car_id,
|
||||||
|
o.check_num,
|
||||||
|
o.quantity,
|
||||||
|
o.sold_at,
|
||||||
|
c.price,
|
||||||
|
c.name as car_name,
|
||||||
|
b.name as car_brand,
|
||||||
|
cc.name as centre_name,
|
||||||
|
o.quantity * c.price as "total!"
|
||||||
|
FROM orders o
|
||||||
|
JOIN cars c ON o.car_id = c.id
|
||||||
|
JOIN brand b ON c.brand_id = b.id
|
||||||
|
JOIN carcentres cc ON c.car_centre_id = cc.id"#
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(orders))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
pub struct AddSaleRequest {
|
||||||
|
/// Name of the car (partial case insensitive search)
|
||||||
|
pub car_name: String,
|
||||||
|
/// Optional check number. If not provided, will be autoincremented
|
||||||
|
pub check_num: Option<i32>,
|
||||||
|
/// Quantity to sell, defaults to 1
|
||||||
|
pub quantity: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a car by name and add a car sale with it
|
||||||
|
#[utoipa::path(post, path = "", request_body = AddSaleRequest, tag = SALES_TAG,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Sale registered successfully"),
|
||||||
|
(status = BAD_REQUEST, description = "Business Logic Error"),
|
||||||
|
(status = NOT_FOUND, description = "Car was not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn add_sale(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<AddSaleRequest>,
|
||||||
|
) -> Result<Json<String>> {
|
||||||
|
sqlx::query!(
|
||||||
|
"CALL add_car_sale($1, $2, $3)",
|
||||||
|
payload.car_name,
|
||||||
|
payload.check_num,
|
||||||
|
payload.quantity.unwrap_or(1)
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json("Sale processed successfully".to_string()))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user