24 Commits

Author SHA1 Message Date
Your Name
9a30857280 style: hide ID column from print view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:58:33 +03:00
Your Name
f750ef3a01 style: convert mobile column widths to % and font-sizes to rem
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:59:30 +03:00
Your Name
8e3cce0bb0 style: set table cell padding to 1px on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:17:28 +03:00
Your Name
44961e3a9c style: reduce table cell padding to 1px 2px on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:15:26 +03:00
Your Name
1cadd41d26 style: wrap table headers at ~5 chars on mobile with word-break break-all
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:14:18 +03:00
Your Name
d53b8b60c9 style: prioritize Название and Расположение columns on mobile
- Инв. номер: 58px -> 44px, Аудитория: 52px -> 34px (both compressed)
- Название: auto (byAud) / auto (allEquipment) — gets most space
- Расположение: shares auto space in allEquipment, 75px in byAud
- Table cell padding: 3px 2px -> 2px 2px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:12:49 +03:00
Your Name
fc82b38c1f feat: add Тип column to allEquipment table; compress Владелец on mobile
- Add Тип (equipment type) column between Кол-во and Владелец
- allequip-table: table-layout fixed with explicit column widths
- Владелец: 60px (compressed), Название gets remaining space

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:09:02 +03:00
Your Name
a315b9033a style: reduce font size on mobile (body 13px, tables 11px)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:04:41 +03:00
Your Name
8715d21ea3 fix: fix byAud table column widths on mobile with table-layout fixed
- table-layout: fixed so column widths are respected
- Владелец: 88px (compressed, select+button fit inside)
- Название: gets remaining space (widest column)
- Расположение: 80px
- ID/Кол-во/Тип: minimal widths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:02:09 +03:00
Your Name
ec6088904c fix: reduce card-body padding and compress owner column on mobile
- card-body padding: 16px -> 8px on mobile
- owner-td: max-width 110px, select+button stack vertically, font 11px
- Remove d-inline w-auto from owner select (was preventing compression)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:56:58 +03:00
Your Name
062d6de913 fix: move inspection action buttons above barcode input; split checked_at into date/time rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:53:00 +03:00
Your Name
73ce68b071 feat: adapt all pages for mobile (Galaxy S10, 360px)
- Remove min-width: 580px from body — was forcing horizontal scroll
- Reduce container padding to px-2 on mobile
- Fix .inv width (was 400px fixed, now auto)
- Add @media (max-width: 575.98px) block: smaller card margins,
  font sizes, full-width forms and select bars
- Audit/owner/admin/inspection forms: col-auto → col-12 col-sm-auto
- Inspection stats: col-md-3 → col-6 col-md-3 (2x2 grid on mobile)
- Inspection action buttons: d-flex flex-wrap gap-2
- Home search input: flex-grow-1 to fill available width
- aud-select-bar: stacks vertically on mobile
- Bump cache-busting version for styles.css and app.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:48:56 +03:00
Your Name
68a8865dc6 fix: bust browser cache for app.js to force reload of updated script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:42:36 +03:00
Your Name
aaf20ac9f3 feat: redesign zametki page layout and date format
- Move "Add" button below textarea
- Move "Resolved" button below date (removed float-end)
- Show only HH:MM for note creation time via new formatTime helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:28:56 +03:00
Your Name
76a9d4c1b3 fix: auto-logout on 401 so stale tokens don't leave frontend in broken state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:27:20 +03:00
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 532 additions and 90 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 # 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');
@@ -82,9 +150,7 @@ createApp({
this.users = []; this.users = [];
this.status = ''; this.status = '';
this.error = ''; this.error = '';
this.view = 'byAud'; window.location.href = '/login';
// опционально: редирект на страницу логина
// window.location.href = '/login';
}, },
authHeaders() { authHeaders() {
const h = {}; const h = {};
@@ -94,6 +160,10 @@ createApp({
async fetchAuth(url, options = {}) { async fetchAuth(url, options = {}) {
const opt = { ...options, headers: { ...(options.headers||{}), ...this.authHeaders() } }; const opt = { ...options, headers: { ...(options.headers||{}), ...this.authHeaders() } };
const res = await fetch(url, opt); const res = await fetch(url, opt);
if (res.status === 401) {
this.logout();
return;
}
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`); throw new Error(`HTTP ${res.status}: ${text}`);
@@ -237,6 +307,16 @@ createApp({
const d = new Date(dateStr); const d = new Date(dateStr);
return d.toLocaleString('ru-RU'); return d.toLocaleString('ru-RU');
}, },
formatTime(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
},
formatDateOnly(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('ru-RU');
},
async loadEquipmentTypes() { async loadEquipmentTypes() {
try { try {
this.equipmentTypes = await fetchJSON('/equipment-types/'); this.equipmentTypes = await fetchJSON('/equipment-types/');
@@ -592,6 +672,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

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>АСУ Инвентаризация</title> <title>АСУ Инвентаризация</title>
<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?v=11" />
</head> </head>
<body> <body>
<header class="no-print"> <header class="no-print">
@@ -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">
@@ -62,15 +63,109 @@
</nav> </nav>
</div> </div>
<div class="container-fluid px-4"> <div class="container-fluid px-2 px-md-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 flex-grow-1" 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">
<h3 class="card-title no-print">Оборудование по аудитории</h3> <h3 class="card-title no-print">Оборудование по аудитории</h3>
<h2 class="print-only print-title">{{ printTitle }}</h2> <h2 class="print-only print-title">{{ printTitle }}</h2>
<div class="mb-2 d-flex align-items-center justify-content-center gap-2 no-print"> <div class="mb-2 d-flex align-items-center justify-content-center gap-2 no-print aud-select-bar">
<label for="aud-select" class="mb-0">Аудитория:</label> <label for="aud-select" class="mb-0">Аудитория:</label>
<select id="aud-select" class="form-select w-auto" v-model="selectedAudId"> <select id="aud-select" class="form-select" style="max-width:220px;" v-model="selectedAudId">
<option value="">— выберите аудиторию —</option> <option value="">— выберите аудиторию —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option> <option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select> </select>
@@ -79,10 +174,10 @@
</div> </div>
<div class="status no-print" :class="{error: !!error}">{{ status }}</div> <div class="status no-print" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table datatable"> <table class="table datatable byaud-table">
<thead> <thead>
<tr> <tr>
<th scope="col">ID</th> <th scope="col" class="no-print">ID</th>
<th scope="col">Инв. номер</th> <th scope="col">Инв. номер</th>
<th scope="col">Название</th> <th scope="col">Название</th>
<th scope="col">Расположение</th> <th scope="col">Расположение</th>
@@ -94,19 +189,19 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="it in oboruds" :key="it.id"> <tr v-for="it in oboruds" :key="it.id">
<td>{{ it.id }}</td> <td class="no-print">{{ it.id }}</td>
<td class="inv">{{ it.invNumber ?? '' }}</td> <td class="inv">{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td> <td>{{ it.nazvanie ?? '' }}</td>
<td class="rasp">{{ it.raspologenie ?? '' }}</td> <td class="rasp">{{ it.raspologenie ?? '' }}</td>
<td class="no-print">{{ it.kolichestvo ?? '' }}</td> <td class="no-print">{{ it.kolichestvo ?? '' }}</td>
<td class="no-print">{{ it.type?.name ?? '' }}</td> <td class="no-print">{{ it.type?.name ?? '' }}</td>
<td class="no-print"> <td class="no-print owner-td">
<template v-if="canEdit"> <template v-if="canEdit">
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedOwnerId"> <select class="form-select form-select-sm" v-model="it.selectedOwnerId">
<option value="">— нет —</option> <option value="">— нет —</option>
<option v-for="ow in owners" :key="ow.id" :value="ow.id">{{ ow.name }}</option> <option v-for="ow in owners" :key="ow.id" :value="ow.id">{{ ow.name }}</option>
</select> </select>
<button class="btn btn-sm btn-outline-primary ms-2" @click="saveOwner(it)">Сохранить</button> <button class="btn btn-sm btn-outline-primary" @click="saveOwner(it)">Сохранить</button>
</template> </template>
<template v-else> <template v-else>
{{ it.owner?.name ?? '' }} {{ it.owner?.name ?? '' }}
@@ -131,7 +226,7 @@
</div> </div>
<div class="status no-print" :class="{error: !!error}">{{ status }}</div> <div class="status no-print" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table datatable"> <table class="table datatable allequip-table">
<thead> <thead>
<tr> <tr>
<th scope="col" class="num-col"></th> <th scope="col" class="num-col"></th>
@@ -140,6 +235,7 @@
<th scope="col" class="aud-col">Аудитория</th> <th scope="col" class="aud-col">Аудитория</th>
<th scope="col">Расположение</th> <th scope="col">Расположение</th>
<th scope="col" class="no-print">Кол-во</th> <th scope="col" class="no-print">Кол-во</th>
<th scope="col" class="no-print">Тип</th>
<th scope="col" class="no-print">Владелец</th> <th scope="col" class="no-print">Владелец</th>
</tr> </tr>
</thead> </thead>
@@ -151,6 +247,7 @@
<td class="aud-col">{{ getAuditoryName(it.aud_id) }}</td> <td class="aud-col">{{ getAuditoryName(it.aud_id) }}</td>
<td class="rasp">{{ it.raspologenie ?? '' }}</td> <td class="rasp">{{ it.raspologenie ?? '' }}</td>
<td class="no-print">{{ it.kolichestvo ?? '' }}</td> <td class="no-print">{{ it.kolichestvo ?? '' }}</td>
<td class="no-print">{{ it.type?.name ?? '' }}</td>
<td class="no-print">{{ it.owner?.name ?? '' }}</td> <td class="no-print">{{ it.owner?.name ?? '' }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -169,16 +266,16 @@
<div v-else> <div v-else>
<h5 class="mt-2">Создать пользователя-админа</h5> <h5 class="mt-2">Создать пользователя-админа</h5>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-auto"> <div class="col-12 col-sm-auto">
<label class="form-label">Логин</label> <label class="form-label">Логин</label>
<input class="form-control" v-model="newAdminUsername" placeholder="username" /> <input class="form-control" v-model="newAdminUsername" placeholder="username" />
</div> </div>
<div class="col-auto"> <div class="col-12 col-sm-auto">
<label class="form-label">Пароль</label> <label class="form-label">Пароль</label>
<input type="password" class="form-control" v-model="newAdminPassword" placeholder="password" /> <input type="password" class="form-control" v-model="newAdminPassword" placeholder="password" />
</div> </div>
<div class="col-auto"> <div class="col-12 col-sm-auto">
<button class="btn btn-success" @click="createAdmin">Создать админа</button> <button class="btn btn-success w-100" @click="createAdmin">Создать админа</button>
</div> </div>
</div> </div>
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div> <div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
@@ -216,12 +313,12 @@
<div v-else> <div v-else>
<h5 class="mt-2">Добавить аудиторию</h5> <h5 class="mt-2">Добавить аудиторию</h5>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-auto"> <div class="col-12 col-sm-auto">
<label class="form-label">Название аудитории</label> <label class="form-label">Название аудитории</label>
<input class="form-control" v-model="newAudName" placeholder="например, 519" /> <input class="form-control" v-model="newAudName" placeholder="например, 519" />
</div> </div>
<div class="col-auto"> <div class="col-12 col-sm-auto">
<button class="btn btn-success" @click="createAuditory">Добавить</button> <button class="btn btn-success w-100" @click="createAuditory">Добавить</button>
</div> </div>
</div> </div>
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div> <div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
@@ -256,12 +353,12 @@
<div v-else> <div v-else>
<h5 class="mt-2">Добавить владельца</h5> <h5 class="mt-2">Добавить владельца</h5>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-auto"> <div class="col-12 col-sm-auto">
<label class="form-label">Имя владельца</label> <label class="form-label">Имя владельца</label>
<input class="form-control" v-model="newOwnerName" placeholder="например, Иванов И.И." /> <input class="form-control" v-model="newOwnerName" placeholder="например, Иванов И.И." />
</div> </div>
<div class="col-auto"> <div class="col-12 col-sm-auto">
<button class="btn btn-success" @click="createOwner">Добавить</button> <button class="btn btn-success w-100" @click="createOwner">Добавить</button>
</div> </div>
</div> </div>
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div> <div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
@@ -324,11 +421,11 @@
<div v-if="canEdit" class="mb-4"> <div v-if="canEdit" class="mb-4">
<h5>Добавить заметку</h5> <h5>Добавить заметку</h5>
<div class="row g-2 align-items-end"> <div class="row g-2">
<div class="col-md-8"> <div class="col-md-8">
<textarea class="form-control" v-model="newZametkaText" rows="3" placeholder="Текст заметки..."></textarea> <textarea class="form-control" v-model="newZametkaText" rows="3" placeholder="Текст заметки..."></textarea>
</div> </div>
<div class="col-auto"> <div class="col-md-8">
<button class="btn btn-success" @click="createZametka">Добавить</button> <button class="btn btn-success" @click="createZametka">Добавить</button>
</div> </div>
</div> </div>
@@ -341,8 +438,10 @@
<div v-for="z in zametki" :key="z.id" class="card mb-2"> <div v-for="z in zametki" :key="z.id" class="card mb-2">
<div class="card-body"> <div class="card-body">
<p class="card-text" style="white-space: pre-wrap;">{{ z.txtzam }}</p> <p class="card-text" style="white-space: pre-wrap;">{{ z.txtzam }}</p>
<small class="text-muted">{{ formatDate(z.created_date) }}</small> <div>
<button v-if="canEdit" class="btn btn-sm btn-outline-success float-end" @click="resolveZametka(z.id)">Решено</button> <small class="text-muted">{{ formatTime(z.created_date) }}</small>
</div>
<button v-if="canEdit" class="btn btn-sm btn-outline-success mt-1" @click="resolveZametka(z.id)">Решено</button>
</div> </div>
</div> </div>
</div> </div>
@@ -483,15 +582,15 @@
<div v-if="!activeInspection"> <div v-if="!activeInspection">
<h5>Начать новую проверку</h5> <h5>Начать новую проверку</h5>
<div class="row g-2 align-items-end mb-3"> <div class="row g-2 align-items-end mb-3">
<div class="col-auto"> <div class="col-12 col-sm-auto">
<label class="form-label">Аудитория (необязательно)</label> <label class="form-label">Аудитория (необязательно)</label>
<select class="form-select" v-model="inspectionAudId"> <select class="form-select" v-model="inspectionAudId">
<option value="">— Всё оборудование —</option> <option value="">— Всё оборудование —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option> <option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select> </select>
</div> </div>
<div class="col-auto"> <div class="col-12 col-sm-auto">
<button class="btn btn-success" @click="startInspection">Начать проверку</button> <button class="btn btn-success w-100" @click="startInspection">Начать проверку</button>
</div> </div>
</div> </div>
@@ -534,41 +633,48 @@
</div> </div>
<!-- Статистика прогресса --> <!-- Статистика прогресса -->
<div class="row mb-3"> <div class="row mb-3 g-2">
<div class="col-md-3"> <div class="col-6 col-md-3">
<div class="card text-center"> <div class="card text-center h-100">
<div class="card-body"> <div class="card-body py-2">
<h5 class="card-title">{{ inspectionStats.total_checked }}</h5> <h5 class="card-title">{{ inspectionStats.total_checked }}</h5>
<p class="card-text">Проверено</p> <p class="card-text small">Проверено</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-6 col-md-3">
<div class="card text-center"> <div class="card text-center h-100">
<div class="card-body"> <div class="card-body py-2">
<h5 class="card-title">{{ inspectionStats.total_expected }}</h5> <h5 class="card-title">{{ inspectionStats.total_expected }}</h5>
<p class="card-text">Всего</p> <p class="card-text small">Всего</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-6 col-md-3">
<div class="card text-center"> <div class="card text-center h-100">
<div class="card-body"> <div class="card-body py-2">
<h5 class="card-title">{{ inspectionStats.total_unknown }}</h5> <h5 class="card-title">{{ inspectionStats.total_unknown }}</h5>
<p class="card-text">Не найдено</p> <p class="card-text small">Не найдено</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-6 col-md-3">
<div class="card text-center bg-success text-white"> <div class="card text-center h-100 bg-success text-white">
<div class="card-body"> <div class="card-body py-2">
<h5 class="card-title">{{ inspectionStats.progress_percent }}%</h5> <h5 class="card-title">{{ inspectionStats.progress_percent }}%</h5>
<p class="card-text">Прогресс</p> <p class="card-text small">Прогресс</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Кнопки действий -->
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-primary" @click="refreshInspectionData">Обновить данные</button>
<button class="btn btn-success" @click="completeInspection">Завершить проверку</button>
<button class="btn btn-secondary" @click="cancelInspection">Отменить</button>
</div>
<!-- Поле для сканирования --> <!-- Поле для сканирования -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Сканируйте штрихкод или введите инв. номер</label> <label class="form-label">Сканируйте штрихкод или введите инв. номер</label>
@@ -591,13 +697,6 @@
</div> </div>
</div> </div>
<!-- Кнопки действий -->
<div class="mt-3 mb-4">
<button class="btn btn-primary me-2" @click="refreshInspectionData">Обновить данные</button>
<button class="btn btn-success me-2" @click="completeInspection">Завершить проверку</button>
<button class="btn btn-secondary" @click="cancelInspection">Отменить</button>
</div>
<!-- Таблица проверенного оборудования (в реальном времени) --> <!-- Таблица проверенного оборудования (в реальном времени) -->
<h5 class="mt-4">Проверенное оборудование</h5> <h5 class="mt-4">Проверенное оборудование</h5>
<div class="table-responsive"> <div class="table-responsive">
@@ -615,7 +714,7 @@
<td>{{ rec.oborud?.invNumber }}</td> <td>{{ rec.oborud?.invNumber }}</td>
<td>{{ rec.oborud?.nazvanie }}</td> <td>{{ rec.oborud?.nazvanie }}</td>
<td>{{ getAuditoryName(rec.oborud?.aud_id) }}</td> <td>{{ getAuditoryName(rec.oborud?.aud_id) }}</td>
<td>{{ formatDate(rec.checked_at) }}</td> <td><div>{{ formatDateOnly(rec.checked_at) }}</div><div>{{ formatTime(rec.checked_at) }}</div></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -636,8 +735,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?v=4" 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

@@ -5,7 +5,7 @@
body { body {
background-color: #E2F3FD; background-color: #E2F3FD;
min-width: 580px; min-width: 0;
} }
.row { .row {
@@ -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 {
@@ -93,7 +125,8 @@ td.inv-col, th.inv-col {
} }
.inv { .inv {
width: 400px; width: auto;
min-width: 80px;
} }
.rasp { .rasp {
@@ -126,6 +159,149 @@ td.inv-col, th.inv-col {
width: 200px; width: 200px;
} }
@media (max-width: 575.98px) {
header h1 {
font-size: 1.2rem;
}
.container-fluid {
padding-left: 8px !important;
padding-right: 8px !important;
}
.card {
margin: 4px;
border-radius: 10px;
}
.rasp {
width: auto;
max-width: 120px;
}
body {
font-size: 0.8rem;
}
.card-body {
padding: 8px;
}
.table td, .table th {
font-size: 0.69rem;
padding: 1px;
}
.byaud-table th,
.allequip-table th {
white-space: normal;
word-break: break-all;
line-height: 1.2;
}
/* byAud table: Название и Расположение — приоритет */
.byaud-table {
table-layout: fixed;
width: 100%;
}
/* ID */
.byaud-table th:nth-child(1),
.byaud-table td:nth-child(1) { width: 6%; }
/* Инв. номер */
.byaud-table th:nth-child(2),
.byaud-table td:nth-child(2) { width: 12%; }
/* Название — auto, gets most remaining space */
/* Расположение */
.byaud-table th:nth-child(4),
.byaud-table td:nth-child(4) { width: 20%; }
/* Кол-во */
.byaud-table th:nth-child(5),
.byaud-table td:nth-child(5) { width: 7%; }
/* Тип */
.byaud-table th:nth-child(6),
.byaud-table td:nth-child(6) { width: 12%; }
/* Владелец */
.byaud-table th:nth-child(7),
.byaud-table td:nth-child(7) { width: 25%; }
/* allEquipment table: Название и Расположение — приоритет */
.allequip-table {
table-layout: fixed;
width: 100%;
}
/* № */
.allequip-table th:nth-child(1),
.allequip-table td:nth-child(1) { width: 5%; }
/* Инв. номер */
.allequip-table th:nth-child(2),
.allequip-table td:nth-child(2) { width: 12%; }
/* Название — auto, gets most space */
/* Аудитория */
.allequip-table th:nth-child(4),
.allequip-table td:nth-child(4) { width: 9%; }
/* Расположение — auto, shares space with Название */
/* Кол-во */
.allequip-table th:nth-child(6),
.allequip-table td:nth-child(6) { width: 7%; }
/* Тип */
.allequip-table th:nth-child(7),
.allequip-table td:nth-child(7) { width: 13%; }
/* Владелец */
.allequip-table th:nth-child(8),
.allequip-table td:nth-child(8) { width: 15%; }
.owner-td select {
width: 100% !important;
max-width: 100%;
margin-bottom: 3px;
font-size: 0.69rem;
}
.owner-td .btn {
width: 100%;
padding: 2px 4px;
font-size: 0.69rem;
margin: 0 !important;
}
.aud-select-bar {
flex-direction: column !important;
align-items: stretch !important;
justify-content: flex-start !important;
}
.aud-select-bar label {
margin-bottom: 4px;
}
.aud-select-bar select,
.aud-select-bar .btn {
width: 100% !important;
max-width: 100% !important;
}
.form-row-mobile {
flex-direction: column !important;
}
.form-row-mobile .col-auto,
.form-row-mobile [class*="col-"] {
width: 100% !important;
max-width: 100% !important;
}
.btn-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn-bar .btn {
flex: 1 1 auto;
margin: 0 !important;
}
}
@media print { @media print {
* { * {
font-family: "Times New Roman", Times, serif; font-family: "Times New Roman", Times, serif;
@@ -177,13 +353,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 +375,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