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>
This commit is contained in:
Your Name
2026-05-11 16:50:52 +03:00
parent 1950bd4d45
commit 2ae18dea27
4 changed files with 173 additions and 5 deletions

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()

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();
}

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

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>