8 Commits

Author SHA1 Message Date
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
13 changed files with 313 additions and 14 deletions

13
.dockerignore Normal file
View File

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

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 # backend/database.py
import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session 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)) SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine))
def get_db(): 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 return obj
@oboruds.get("/", response_model=list[schemas.OborudRead]) @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) query = db.query(models.Oboruds)
if unassigned: if unassigned:
query = query.filter(models.Oboruds.aud_id == None) query = query.filter(models.Oboruds.aud_id == None)
elif aud_id is not None: elif aud_id is not None:
query = query.filter(models.Oboruds.aud_id == aud_id) 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: if sort_by_inv:
query = query.order_by(models.Oboruds.invNumber.asc()) query = query.order_by(models.Oboruds.invNumber.asc())
return query.all() 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({ createApp({
data() { data() {
return { return {
view: 'byAud', view: 'home',
auditories: [], auditories: [],
selectedAudId: '', selectedAudId: '',
oboruds: [], oboruds: [],
@@ -27,6 +27,11 @@ createApp({
status: '', status: '',
error: '', error: '',
printTitle: '', printTitle: '',
// home view
homeSearch: '',
homeUnassigned: [],
homeSearchResults: [],
homeSearchDone: false,
// auth/user management // auth/user management
token: '', token: '',
role: '', role: '',
@@ -72,6 +77,69 @@ createApp({
canEdit() { return this.isAdmin || this.isEditor; }, canEdit() { return this.isAdmin || this.isEditor; },
}, },
methods: { 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() { logout() {
try { try {
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
@@ -592,6 +660,7 @@ createApp({
this.loadAuditories(); this.loadAuditories();
this.loadOwners(); this.loadOwners();
this.loadEquipmentTypes(); this.loadEquipmentTypes();
this.loadHomeUnassigned();
if (this.isAdmin) { if (this.isAdmin) {
this.loadUsers(); 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"> <div class="row no-print">
<nav class="no-print navbar navbar-expand-lg navbar-light"> <nav class="no-print navbar navbar-expand-lg navbar-light">
<div class="container-fluid"> <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"> <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> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <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"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Оборудование</a> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Оборудование</a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
@@ -63,6 +64,100 @@
</div> </div>
<div class="container-fluid px-4"> <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 v-if="view==='byAud'" class="row">
<div class="card col-12"> <div class="card col-12">
<div class="card-body"> <div class="card-body">
@@ -636,8 +731,8 @@
</div> </div>
</div> </div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <script src="/app/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/bootstrap.bundle.min.js"></script>
<script src="/app/app.js" defer></script> <script src="/app/app.js" defer></script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,9 @@
<link rel="stylesheet" href="/app/bootstrap.min.css" /> <link rel="stylesheet" href="/app/bootstrap.min.css" />
<link rel="stylesheet" href="/app/styles.css" /> <link rel="stylesheet" href="/app/styles.css" />
<style> <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; } .muted { color: #6c757d; font-size: 0.9rem; }
</style> </style>
</head> </head>
@@ -17,7 +19,7 @@
<h2>Авторизация</h2> <h2>Авторизация</h2>
</header> </header>
<main class="container"> <main>
<div class="card login-card"> <div class="card login-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title mb-3">Вход</h5> <h5 class="card-title mb-3">Вход</h5>

View File

@@ -38,6 +38,38 @@ button {
background-color: #E07D54; background-color: #E07D54;
margin-top: 5px; margin-top: 5px;
margin-bottom: 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 { .card {
@@ -177,13 +209,13 @@ td.inv-col, th.inv-col {
} }
.table { .table {
border: 1px solid #000000; border: 1px solid #E07D54;
margin-top: 20px; margin-top: 20px;
font-size: 12pt; font-size: 12pt;
} }
.table th, .table td { .table th, .table td {
border: 1px solid #000000; border: 1px solid #E07D54;
padding: 5px; padding: 5px;
font-size: 12pt; font-size: 12pt;
word-break: normal; word-break: normal;
@@ -199,19 +231,19 @@ td.inv-col, th.inv-col {
} }
table.rs-table-bordered { table.rs-table-bordered {
border: 1px solid #000000; border: 1px solid #E07D54;
margin-top: 20px; margin-top: 20px;
font-size: 14pt; font-size: 14pt;
} }
table.rs-table-bordered > thead > tr > th { table.rs-table-bordered > thead > tr > th {
border: 1px solid #000000; border: 1px solid #E07D54;
padding: 2px; padding: 2px;
font-size: 14pt; font-size: 14pt;
} }
table.rs-table-bordered > tbody > tr > td { table.rs-table-bordered > tbody > tr > td {
border: 1px solid #000000; border: 1px solid #E07D54;
padding: 10px; padding: 10px;
font-size: 14pt; font-size: 14pt;
} }

File diff suppressed because one or more lines are too long