9 Commits

Author SHA1 Message Date
Your Name
04ff1b012b chore local env files 2026-05-12 12:24:05 +03:00
Your Name
31f1fcecc4 feat: add Docker Compose setup with bind-mount appdata directory
- Dockerfile builds image with app source in /app_src
- docker-entrypoint.sh syncs code from image on each start, inits DB
- docker-compose.yml mounts ./appdata:/app for easy backup
- backend/init_db.py for safe DB init (no drop_all)
- backend/database.py supports DATABASE_URL env var
- .dockerignore excludes venv, __pycache__, *.db, .git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:15:37 +03:00
Your Name
67e7ab6f94 style: increase button padding via .btn override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:05:40 +03:00
Your Name
913e23b966 style: add padding to buttons so text is not cramped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:04:41 +03:00
Your Name
7219fbdada style: revert button color to original #E07D54
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:03:48 +03:00
Your Name
92fc020d94 style: change button color to black (#000000)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:01:25 +03:00
Your Name
dca8a0dbcf style: replace blue buttons with original #E07D54 color
Override Bootstrap btn-primary and btn-outline-primary to match the original project's button color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:59:22 +03:00
Your Name
61d12f4972 feat: vendor Vue 3 and Bootstrap JS locally
Remove CDN dependencies to ensure the app works without internet access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:56:26 +03:00
Your Name
2ae18dea27 feat: add home page with search/unassigned tables; center login card
- Add home view as default: search by inv number, unassigned equipment
  table with auditory assignment, search results (assigned) table
- Add inv_number query param to GET /oboruds/ for backend search
- Center login card vertically and horizontally via flexbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:50:52 +03:00
15 changed files with 313 additions and 31 deletions

View File

@@ -1,17 +0,0 @@
{
"permissions": {
"allow": [
"Bash(backend\\\\venv\\\\Scripts\\\\python.exe -m uvicorn backend.main:app --reload)",
"Bash(python:*)",
"Bash(backend/venv/Scripts/python.exe:*)",
"Bash(backend/venv/Scripts/pip.exe list:*)",
"Bash(backend/venv/Scripts/pip.exe install:*)",
"Bash(backend/venv/Scripts/uvicorn.exe backend.main:app)",
"Bash(curl -s http://localhost:8000/ping)",
"Bash(curl -s http://127.0.0.1:8000/ping)",
"Bash(taskkill /F /IM python.exe)",
"Bash(netstat -ano)",
"Bash(taskkill:*)"
]
}
}

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
venv/
backend/venv/
**/__pycache__/
*.pyc
*.pyo
*.db
.git/
.gitignore
.vscode/
.idea/
data/
*.xls
*.xlsx

BIN
.gitignore vendored

Binary file not shown.

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
COPY requirements.txt /app_src/requirements.txt
RUN pip install --no-cache-dir -r /app_src/requirements.txt
COPY backend/ /app_src/backend/
COPY frontend/ /app_src/frontend/
COPY docker-entrypoint.sh /app_src/docker-entrypoint.sh
RUN chmod +x /app_src/docker-entrypoint.sh
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["/app_src/docker-entrypoint.sh"]

View File

@@ -1,10 +1,13 @@
# backend/database.py
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
SQLALCHEMY_DATABASE_URL = "sqlite:///./app.db" # или PostgreSQL URL
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
connect_args = {"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine))
def get_db():

27
backend/init_db.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy.orm import sessionmaker
from backend.models import Base, User
from backend.database import engine
from backend.security import get_password_hash
def init_db():
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
try:
if not db.query(User).filter(User.username == 'admin').first():
db.add(User(
username='admin',
password_hash=get_password_hash('admin'),
role='admin',
))
db.commit()
print("Created default admin user (login: admin / password: admin)")
finally:
db.close()
if __name__ == "__main__":
init_db()

View File

@@ -15,12 +15,14 @@ def create_oborud(item: schemas.OborudCreate, db: Session = Depends(database.get
return obj
@oboruds.get("/", response_model=list[schemas.OborudRead])
def list_oboruds(aud_id: Optional[int] = None, sort_by_inv: bool = False, unassigned: bool = False, db: Session = Depends(database.get_db)):
def list_oboruds(aud_id: Optional[int] = None, sort_by_inv: bool = False, unassigned: bool = False, inv_number: Optional[int] = None, db: Session = Depends(database.get_db)):
query = db.query(models.Oboruds)
if unassigned:
query = query.filter(models.Oboruds.aud_id == None)
elif aud_id is not None:
query = query.filter(models.Oboruds.aud_id == aud_id)
if inv_number is not None:
query = query.filter(models.Oboruds.invNumber == inv_number)
if sort_by_inv:
query = query.order_by(models.Oboruds.invNumber.asc())
return query.all()

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- ./appdata:/app
environment:
- DATABASE_URL=sqlite:////app/data/app.db
restart: unless-stopped

11
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
# Sync app code from image on every start (keeps code fresh after rebuilds)
rm -rf /app/backend /app/frontend
cp -r /app_src/backend /app_src/frontend /app/
mkdir -p /app/data
python -m backend.init_db
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000

View File

@@ -17,7 +17,7 @@ async function fetchJSON(url) {
createApp({
data() {
return {
view: 'byAud',
view: 'home',
auditories: [],
selectedAudId: '',
oboruds: [],
@@ -27,6 +27,11 @@ createApp({
status: '',
error: '',
printTitle: '',
// home view
homeSearch: '',
homeUnassigned: [],
homeSearchResults: [],
homeSearchDone: false,
// auth/user management
token: '',
role: '',
@@ -72,6 +77,69 @@ createApp({
canEdit() { return this.isAdmin || this.isEditor; },
},
methods: {
async showHome() {
this.view = 'home';
this.status = '';
this.error = '';
this.homeSearch = '';
this.homeSearchResults = [];
this.homeSearchDone = false;
await this.loadHomeUnassigned();
},
async loadHomeUnassigned() {
this.status = 'Загрузка нераспределённого оборудования…';
this.error = '';
try {
const data = await fetchJSON('/oboruds/?unassigned=true&sort_by_inv=true');
this.homeUnassigned = data.map(it => ({ ...it, selectedAudId: '' }));
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить данные';
this.status = '';
}
},
async doHomeSearch() {
const q = this.homeSearch.trim();
if (!q) {
this.status = 'Введите инвентарный номер';
return;
}
this.status = 'Поиск…';
this.error = '';
this.homeSearchDone = false;
try {
const data = await fetchJSON(`/oboruds/?inv_number=${encodeURIComponent(q)}`);
this.homeSearchResults = data.filter(it => it.aud_id).map(it => ({ ...it, selectedAudId: '' }));
this.homeSearchDone = true;
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось выполнить поиск';
this.status = '';
}
},
async assignToAuditory(item) {
if (!item.selectedAudId) {
this.status = 'Выберите аудиторию';
return;
}
try {
this.status = 'Сохранение…';
this.error = '';
await this.fetchAuth(`/oboruds/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aud_id: item.selectedAudId }),
});
await this.loadHomeUnassigned();
this.status = 'Сохранено';
} catch (e) {
console.error(e);
this.error = 'Не удалось назначить аудиторию';
this.status = '';
}
},
logout() {
try {
localStorage.removeItem('access_token');
@@ -592,6 +660,7 @@ createApp({
this.loadAuditories();
this.loadOwners();
this.loadEquipmentTypes();
this.loadHomeUnassigned();
if (this.isAdmin) {
this.loadUsers();
}

7
frontend/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -18,12 +18,13 @@
<div class="row no-print">
<nav class="no-print navbar navbar-expand-lg navbar-light">
<div class="container-fluid">
<a class="navbar-brand" href="/app/">Главная</a>
<a class="navbar-brand" href="#" @click.prevent="showHome">Главная</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="#" @click.prevent="showHome">Главная</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Оборудование</a>
<ul class="dropdown-menu">
@@ -63,6 +64,100 @@
</div>
<div class="container-fluid px-4">
<!-- Главная страница -->
<div v-if="view==='home'">
<div class="row">
<div class="card col-md-6 col-10">
<div class="card-body">
<form @submit.prevent="doHomeSearch">
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control w-auto" v-model="homeSearch" placeholder="инвентарный номер" />
<button class="btn btn-primary" type="submit">Найти</button>
</div>
</form>
</div>
</div>
</div>
<div class="row mt-3">
<div class="card col-md-10 col-12">
<div class="card-body">
<h3 class="card-title">Нераспределённые</h3>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория</th>
</tr>
</thead>
<tbody>
<tr v-for="it in homeUnassigned" :key="it.id">
<td>{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td>
<template v-if="canEdit">
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedAudId">
<option value="">— выберите —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
<button class="btn btn-sm btn-primary ms-2" @click="assignToAuditory(it)">Назначить</button>
</template>
<template v-else></template>
</td>
</tr>
<tr v-if="homeUnassigned.length === 0">
<td colspan="3" class="text-muted text-center">Нераспределённого оборудования нет</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-3" v-if="homeSearchResults.length > 0">
<div class="card col-md-10 col-12">
<div class="card-body">
<h3 class="card-title">Распределённые</h3>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория исходная</th>
<th scope="col" v-if="canEdit">Аудитория переноса</th>
</tr>
</thead>
<tbody>
<tr v-for="it in homeSearchResults" :key="it.id">
<td>{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td>{{ getAuditoryName(it.aud_id) }}</td>
<td v-if="canEdit">
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedAudId">
<option value="">— не менять —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
<button class="btn btn-sm btn-outline-primary ms-2" :disabled="!it.selectedAudId" @click="assignToAuditory(it)">Перенести</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-2" v-if="homeSearchDone && homeSearchResults.length === 0">
<div class="col-12 text-center text-muted">Ничего не найдено по запросу «{{ homeSearch }}»</div>
</div>
</div>
<div v-if="view==='byAud'" class="row">
<div class="card col-12">
<div class="card-body">
@@ -636,8 +731,8 @@
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/app/vue.global.prod.js"></script>
<script src="/app/bootstrap.bundle.min.js"></script>
<script src="/app/app.js" defer></script>
</body>
</html>

View File

@@ -7,7 +7,9 @@
<link rel="stylesheet" href="/app/bootstrap.min.css" />
<link rel="stylesheet" href="/app/styles.css" />
<style>
.login-card { max-width: 420px; margin: 40px auto; }
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex: 1; display: flex; align-items: center; justify-content: center; width: 100%; }
.login-card { max-width: 420px; width: 100%; margin: 0; }
.muted { color: #6c757d; font-size: 0.9rem; }
</style>
</head>
@@ -17,7 +19,7 @@
<h2>Авторизация</h2>
</header>
<main class="container">
<main>
<div class="card login-card">
<div class="card-body">
<h5 class="card-title mb-3">Вход</h5>

View File

@@ -38,6 +38,38 @@ button {
background-color: #E07D54;
margin-top: 5px;
margin-bottom: 5px;
padding: 6px 14px;
}
.btn {
padding: 8px 20px;
}
.btn-sm {
padding: 5px 12px;
}
.btn-primary {
background-color: #E07D54;
border-color: #E07D54;
color: #fff;
}
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
background-color: #c86a3d;
border-color: #c86a3d;
color: #fff;
}
.btn-outline-primary {
color: #E07D54;
border-color: #E07D54;
}
.btn-outline-primary:hover, .btn-outline-primary:focus, .btn-outline-primary:active {
background-color: #E07D54;
border-color: #E07D54;
color: #fff;
}
.card {
@@ -177,13 +209,13 @@ td.inv-col, th.inv-col {
}
.table {
border: 1px solid #000000;
border: 1px solid #E07D54;
margin-top: 20px;
font-size: 12pt;
}
.table th, .table td {
border: 1px solid #000000;
border: 1px solid #E07D54;
padding: 5px;
font-size: 12pt;
word-break: normal;
@@ -199,19 +231,19 @@ td.inv-col, th.inv-col {
}
table.rs-table-bordered {
border: 1px solid #000000;
border: 1px solid #E07D54;
margin-top: 20px;
font-size: 14pt;
}
table.rs-table-bordered > thead > tr > th {
border: 1px solid #000000;
border: 1px solid #E07D54;
padding: 2px;
font-size: 14pt;
}
table.rs-table-bordered > tbody > tr > td {
border: 1px solid #000000;
border: 1px solid #E07D54;
padding: 10px;
font-size: 14pt;
}

File diff suppressed because one or more lines are too long