1
0

Add third laboratory work for Database Server Solutions class

This commit is contained in:
2025-12-05 01:13:57 +02:00
parent cdd9efdca5
commit a815db313f
45 changed files with 8198 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Серверні Рішення Баз Даних
+1
View File
@@ -0,0 +1 @@
export DATABASE_URL="postgres://postgres:password@localhost:5432/car_shop"
+1
View File
@@ -0,0 +1 @@
/target
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+2935
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
+50
View File
@@ -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
![Image of Swagger UI](img/swagger.png)
### Website Dashboard
![Image showing the website dashboad page](img/dashboard.png)
### Inventory
![Image showing the website inventory page](img/inventory.png)
![Image showing the filtered inventory page](img/inventory-filter.png)
## Sales
![Image showing the website sales page](img/sales.png)
![Image showing an error in the menu of adding a new sale](img/new-sale.png)
+1
View File
@@ -0,0 +1 @@
node_modules
+23
View File
@@ -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;
+7
View File
@@ -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,
});
+23
View File
@@ -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,
},
},
])
+36
View File
@@ -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>
+15
View File
@@ -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>
);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "CarShop Dashboard",
"description": "A RESTful client for the CarShop API demonstrating stored procedures, functions, and error handling.",
"requestFramePermissions": []
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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;
+223
View File
@@ -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;
+85
View File
@@ -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"]
}
+29
View File
@@ -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"]
}
+58
View File
@@ -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;
}
+19
View File
@@ -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

+133
View File
@@ -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;
+158
View File
@@ -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 }))
}
+53
View File
@@ -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()
}
}
+84
View File
@@ -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();
}
+23
View File
@@ -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())
}
+89
View File
@@ -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()))
}