diff --git a/Gemfile b/Gemfile index c84db59..4080fe1 100644 --- a/Gemfile +++ b/Gemfile @@ -74,3 +74,5 @@ end gem "authentication-zero", "~> 4.0" + +gem "rqrcode", "~> 3.2" diff --git a/Gemfile.lock b/Gemfile.lock index 538cea6..4ca31e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,6 +103,7 @@ GEM xpath (~> 3.2) childprocess (5.1.0) logger (~> 1.5) + chunky_png (1.4.0) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -292,6 +293,10 @@ GEM reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) + rqrcode (3.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.1.0) rubocop (1.86.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -428,6 +433,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.3) + rqrcode (~> 3.2) rubocop-rails-omakase selenium-webdriver solid_cable @@ -469,6 +475,7 @@ CHECKSUMS bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d @@ -553,6 +560,8 @@ CHECKSUMS regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rqrcode (3.2.0) sha256=64c1494ca6bb67d731330f38b50e3fd09eeab4f5dcd04b608e21218d1d0b9542 + rqrcode_core (2.1.0) sha256=f303b85df89c1b8fc5ee8dc19808c9dc4330e6329b660d99d4a8cbb36ca13051 rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..51e937f --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,15 @@ +class DashboardController < ApplicationController + def index + # 1. Zählt alle registrierten Hardware-Unikate + @total_items = Item.count + + # 2. Berechnet den Gesamtwert aller Geräte (summiert das Feld :price) + @total_value = Item.sum(:price) + + # 3. Zählt die Artikel, die weder einem User noch einem Raum gehören (= im Lager liegen) + @items_in_storage = Item.where(user_id: nil, room_id: nil).count + + # 4. Holt die letzten 10 registrierten Artikel für die Aktivitätenanzeige + @recent_items = Item.order(created_at: :desc).limit(10).includes(:category, :user, :room) + end +end diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb new file mode 100644 index 0000000..97a922b --- /dev/null +++ b/app/controllers/items_controller.rb @@ -0,0 +1,81 @@ +class ItemsController < ApplicationController + before_action :set_item, only: %i[ show edit update destroy ] + + # GET /items or /items.json + def index + @items = Item.all.includes(:category, :user, :room).order(created_at: :desc) + end + + # GET /items/1 or /items/1.json + def show + @assignment_logs = @item.assignment_logs.includes(:user, :room).order(assigned_at: :desc) + end + + # GET /items/new + def new + @item = Item.new + end + + # GET /items/1/edit + def edit + end + + # POST /items or /items.json + def create + @item = Item.new(item_params) + + respond_to do |format| + if @item.save + format.html { redirect_to @item, notice: "Artikel '#{@item.name}' wurde erfolgreich im System registriert." } + format.json { render :show, status: :created, location: @item } + else + format.html { render :new, status: :unprocessable_content } + format.json { render json: @item.errors, status: :unprocessable_content } + end + end + end + + # PATCH/PUT /items/1 or /items/1.json + def update + respond_to do |format| + if @item.update(item_params) + format.html { redirect_to @item, notice: "Artikel '#{@item.name} wurde erfolgreich aktualisiert.", status: :see_other } + format.json { render :show, status: :ok, location: @item } + else + format.html { render :edit, status: :unprocessable_content } + format.json { render json: @item.errors, status: :unprocessable_content } + end + end + end + + # DELETE /items/1 or /items/1.json + def destroy + @item.destroy! + + respond_to do |format| + format.html { redirect_to items_path, notice: "Artikel wurde erfolgreich aus dem System gelöscht", status: :see_other } + format.json { head :no_content } + end + end + + private + + def set_item + @item = Item.find(params.expect(:id)) + end + + # Strong Parameters: Schützt vor Mass-Assignment-Injections + def item_params + params.require(:item).permit( + :name, + :sku, + :sticker_id, + :serial_number, + :price, + :notes, + :category_id, + :user_id, # Für die flexible Zuweisung an Mitarbeiter + :room_id # Für die flexible Zuweisung an Räume + ) + end +end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb new file mode 100644 index 0000000..a94ddfc --- /dev/null +++ b/app/helpers/dashboard_helper.rb @@ -0,0 +1,2 @@ +module DashboardHelper +end diff --git a/app/helpers/items_helper.rb b/app/helpers/items_helper.rb new file mode 100644 index 0000000..cff0c9f --- /dev/null +++ b/app/helpers/items_helper.rb @@ -0,0 +1,2 @@ +module ItemsHelper +end diff --git a/app/javascript/controllers/assignment_controller.js b/app/javascript/controllers/assignment_controller.js new file mode 100644 index 0000000..5410755 --- /dev/null +++ b/app/javascript/controllers/assignment_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwire/stimulus" + +export default class extends Controller { + static targets = [ "userSection", "roomSection" ] + + toggle(event) { + const value = event.target.value + const userDropdown = this.userSectionTarget.querySelector('select') + const roomDropdown = this.roomSectionTarget.querySelector('select') + + if (value === "user") { + this.userSectionTarget.classList.remove("hidden") + this.roomSectionTarget.classList.add("hidden") + roomDropdown.value = "" // Raum-ID löschen, da ein Artikel nur einen Inhaber haben kann + } else if (value === "room") { + this.roomSectionTarget.classList.remove("hidden") + this.userSectionTarget.classList.add("hidden") + userDropdown.value = "" // User-ID löschen + } else { + // Hauptlager ausgewählt -> Beide ausblenden und Werte in der DB nullen + this.userSectionTarget.classList.add("hidden") + this.roomSectionTarget.classList.add("hidden") + userDropdown.value = "" + roomDropdown.value = "" + } + } +} diff --git a/app/javascript/controllers/scanner_controller.js b/app/javascript/controllers/scanner_controller.js new file mode 100644 index 0000000..69bffd3 --- /dev/null +++ b/app/javascript/controllers/scanner_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwire/stimulus" + +export default class extends Controller { + static targets = [ "input", "preview", "modal" ] + + connect() { + this.html5QrCode = null + } + + // Öffnet das Modal und startet den Kamera-Stream + startCamera(event) { + event.preventDefault() + + // Modal anzeigen + this.modalTarget.classList.remove("hidden") + + // Neue Instanz auf dem Preview-Div mit der ID des Elements erzeugen + this.html5QrCode = new Html5Qrcode(this.previewTarget.id) + + // FPS-Rate und Scan-Rahmen (250x250px) festlegen + const config = { fps: 10, qrbox: { width: 250, height: 250 } } + + this.html5QrCode.start( + { facingMode: "environment" }, // Erzwingt die rückseitige Hauptkamera bei Handys + config, + (decodedText, decodedResult) => { + // SUCCESS: Code erkannt! + this.inputTarget.value = decodedText // Trägt die ID (z.B. 10024) ins Textfeld ein + this.stopCamera() // Stoppt die Kamera und schließt das Fenster + }, + (errorMessage) => { + // Kontinuierlicher Scan-Loop (Fehler ignorieren, wenn kein QR-Code im Bild ist) + } + ).catch((err) => { + console.error("Kamera-Zugriff verweigert oder blockiert:", err) + }) + } + + // Schließt das Modal und beendet den Stream sauber + stopCamera() { + if (this.html5QrCode && this.html5QrCode.isScanning) { + this.html5QrCode.stop().then(() => { + this.modalTarget.classList.add("hidden") + }).catch((err) => console.error("Fehler beim Beenden des Streams:", err)) + } else { + this.modalTarget.classList.add("hidden") + } + } +} diff --git a/app/models/item.rb b/app/models/item.rb index ccbeca3..faf1108 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -1,3 +1,5 @@ +require "rqrcode" + class Item < ApplicationRecord belongs_to :category belongs_to :user, optional: true # Optional, falls im Raum oder Lager @@ -13,6 +15,22 @@ class Item < ApplicationRecord # Überwacht Besitzer- oder Raumwechsel für die Historie before_save :track_assignment_changes, if: -> { will_save_change_to_user_id? || will_save_change_to_room_id? } + def generate_qr_code + return if sticker_id.blank? + + # Erzeugt das QR-Code-Objekt basierend auf deiner vorgedruckten Sticker-ID + qrcode = RQRCode::QRCode.new(sticker_id.to_s) + + # Rendert den QR-Code als SVG-Vektorgrafik (perfekt scharf für Bildschirme) + qrcode.as_svg( + color: "000", # Farbe: Schwarz + shape_rendering: "crispEdges", # Erzwingt scharfe Kanten im Browser + module_size: 4, # Kompakte Größe + standalone: true, + use_path: true + ).html_safe # Sagt Rails, dass das HTML unbedenklich ausgegeben werden darf + end + private def either_user_or_room diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index 77aee76..2cfbbb3 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -1,6 +1,6 @@ -<% content_for :title, "Inventar-Kategorien" %> +<% content_for :title, "Kategorien" %>
Objekte im System
+Aktuell im Lager
+Gesamtwert Inventar
+<%= item.name %>
++ <% if item.user.present? %> + Zugewiesen an: 👤 <%= item.user.name %> + <% elsif item.room.present? %> + Standort: 📍 <%= item.room.name_with_building %> + <% else %> + 📦 Im Hauptlager + <% end %> +
++ <%= item.new_record? ? "Trage hier die Unikat-Daten wie Seriennummer und Sticker-ID ein." : "Aktualisiere die Daten oder verändere den aktuellen Standort des Geräts." %> +
+Dies entfernt das Gerät unwiderruflich aus dem Bestand. Die bisherige Verlaufshistorie wird dabei ebenfalls gelöscht.
+| Artikel / Details | +Seriennummer (SN) | +Aktueller Standort / Inhaber | +Sticker-ID | +Wert | +Aktionen | +
|---|---|---|---|---|---|
|
+ <%= item.name %>
+ SKU: <%= item.sku %> • <%= item.category.name %>
+ |
+
+
+ + <%= item.serial_number %> + | + + ++ <% if item.user.present? %> + + 👤 <%= item.user.name %> + + <% elsif item.room.present? %> + + 📍 <%= item.room.name_with_building %> + + <% else %> + + 📦 Im Hauptlager + + <% end %> + | + + ++ #<%= item.sticker_id %> + | + + ++ <%= number_to_currency(item.price, unit: "€", separator: ",", delimiter: ".", format: "%n %u") %> + | + + ++ <%= link_to "Details", item_path(item), class: "text-blue-600 hover:text-blue-900 transition" %> + <%= link_to "Bearbeiten", edit_item_path(item), class: "text-gray-500 hover:text-gray-700 transition" %> + | + +
Bisher sind keine Inventargegenstände erfasst.
+Klicke oben rechts auf "Artikel hinzufügen", um das erste Gerät einzubuchen.
+Artikelnummer (SKU)
+<%= @item.sku %>
+Seriennummer (Hersteller)
+<%= @item.serial_number %>
+Anschaffungspreis
++ <%= number_to_currency(@item.price, unit: "€", separator: ",", delimiter: ".", format: "%n %u") %> +
+Registriert am
+<%= l(@item.created_at, format: :short) %>
+Beschreibung / Notizen
+<%= @item.notes %>
+In Benutzung (Mitarbeiter)
+<%= @item.user.department&.name || "Keine Abteilung hinterlegt" %>
+Fest verbaut / Zugewiesener Raum
+<%= @item.room.building %> • <%= @item.room.floor %>
+Verfügbar
+Bereit zur Zuweisung an Mitarbeiter oder Räume.
+Interne Kennzeichnung
+ + +Inventarnummer
+#<%= @item.sticker_id %>
+Vorgedrucktes Etikett vom A4-Bogen. Scan im System führt direkt hierher.
+