feat(temper_rejects): "Maschinen" filter label + brighter entry-card meta

- Rename left filter heading Pressen → Maschinen (de.ts)
- Bump entry-card meta info one step in size and brightness
  (article, WAGEN/ETIKETT labels+values, count labels, "getempert vor …")
- Ship rebuilt dist bundle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Erik 2026-07-02 20:16:39 +02:00
parent 83d86dfe90
commit 10aac9ac5f
12 changed files with 1022 additions and 958 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -9,8 +9,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausschuss-Station</title>
<script type="module" crossorigin src="/module/temper_rejects/assets/index-D0flZxiB.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-mkbUZDoP.css">
<script type="module" crossorigin src="/module/temper_rejects/assets/index-DceU8_gJ.js"></script>
<link rel="stylesheet" crossorigin href="/module/temper_rejects/assets/index-Oxhdj_dg.css">
</head>
<body>
<div id="app"></div>

View File

@ -1,136 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialog from 'primevue/dialog'
import Select from 'primevue/select'
import InputNumber from 'primevue/inputnumber'
import Button from 'primevue/button'
import type { RejectEntry, Reason, RejectRequest } from '@/types/station'
const props = defineProps<{ entry: RejectEntry | null; reasons: Reason[]; submitting: boolean }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'submit', req: RejectRequest): void }>()
const { t, locale } = useI18n()
const count = ref<number | null>(null)
const reasonId = ref<number | null>(null)
const touched = ref(false)
const visible = computed({
get: () => props.entry !== null,
set: (v: boolean) => {
if (!v) emit('close')
},
})
// Reset the form whenever a new entry opens the dialog.
watch(
() => props.entry,
(e) => {
if (e) {
count.value = null
reasonId.value = null
touched.value = false
}
},
)
const reasonOptions = computed(() =>
props.reasons.map((r) => ({ id: r.id, label: locale.value === 'en' ? r.reason_en : r.reason_de })),
)
const valid = computed(() => (count.value ?? 0) > 0 && reasonId.value !== null)
function onBook() {
touched.value = true
if (!valid.value || !props.entry) return
emit('submit', {
press: props.entry.press,
orderNo: props.entry.orderNo,
ttId: props.entry.ttId,
count: count.value as number,
reason: reasonId.value as number,
})
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:closable="!submitting"
:draggable="false"
:style="{ width: '30rem', maxWidth: '94vw' }"
:header="t('dialog.title')"
>
<div v-if="entry" class="body">
<p class="for">{{ t('dialog.for', { cart: entry.cartId, order: entry.orderNo }) }}</p>
<label class="fld">
<span class="lab">{{ t('dialog.count') }} <em>({{ t('dialog.pieces') }})</em></span>
<InputNumber
v-model="count"
:min="1"
:max="entry.partsGood"
show-buttons
button-layout="horizontal"
:input-style="{ width: '4rem', textAlign: 'center' }"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
/>
<span v-if="touched && (count ?? 0) <= 0" class="err">{{ t('dialog.needCount') }}</span>
</label>
<label class="fld">
<span class="lab">{{ t('dialog.reason') }}</span>
<Select
v-model="reasonId"
:options="reasonOptions"
option-label="label"
option-value="id"
:placeholder="t('dialog.chooseReason')"
/>
<span v-if="touched && reasonId === null" class="err">{{ t('dialog.needReason') }}</span>
</label>
</div>
<template #footer>
<Button :label="t('dialog.cancel')" text :disabled="submitting" @click="emit('close')" />
<Button :label="t('dialog.confirm')" severity="danger" :loading="submitting" @click="onBook" />
</template>
</Dialog>
</template>
<style scoped>
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
}
.for {
margin: 0;
font-family: var(--mono);
font-size: 0.8rem;
color: var(--ink-dim);
}
.fld {
display: flex;
flex-direction: column;
gap: 6px;
}
.lab {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink);
}
.lab em {
color: var(--ink-faint);
font-style: normal;
font-size: 0.72rem;
}
.err {
font-size: 0.72rem;
color: var(--ausschuss);
}
</style>

View File

@ -5,7 +5,8 @@ import TemperBelt from '@/components/TemperBelt.vue'
import type { RejectEntry } from '@/types/station'
const props = defineProps<{ entry: RejectEntry }>()
const emit = defineEmits<{ (e: 'book', entry: RejectEntry): void }>()
// Forward the raw click event; the page anchors the booking popover to this button.
const emit = defineEmits<{ (e: 'book', ev: MouseEvent): void }>()
const { t, locale } = useI18n()
@ -47,7 +48,7 @@ const relTime = computed(() => {
<div class="belt-wrap">
<TemperBelt :flow="entry.flow" />
<button type="button" class="book" @click="emit('book', entry)">{{ t('entry.book') }}</button>
<button type="button" class="book" @click="emit('book', $event)">{{ t('entry.book') }}</button>
</div>
</article>
</template>
@ -65,7 +66,7 @@ const relTime = computed(() => {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
padding: 4px 12px;
background: var(--panel-3);
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
@ -73,25 +74,31 @@ const relTime = computed(() => {
.press {
font-family: var(--mono);
font-weight: 800;
font-size: 0.9rem;
/* Machine number is the row's headline: no box, near the full header height.
line-height < 1 crops the font's own leading so the tall number doesn't
inflate the row (press values are caps+digits, e.g. P5 no descenders to clip). */
font-size: 2.1rem;
line-height: 0.85;
color: var(--ink);
background: var(--ground);
border: 1px solid var(--line-2);
border-radius: var(--r);
padding: 3px 9px;
}
.ord {
display: flex;
flex-direction: column;
line-height: 1.15;
/* The box moves off the press onto the order info sitting right next to it.
Text sizes below stay put boxed, not shrunk. */
background: var(--ground);
border: 1px solid var(--line-2);
border-radius: var(--r);
padding: 3px 10px;
}
.on {
font-weight: 700;
font-size: 0.9rem;
}
.art {
font-size: 0.66rem;
color: var(--ink-dim);
font-size: 0.74rem;
color: var(--ink);
}
.spacer {
margin-left: auto;
@ -99,17 +106,17 @@ const relTime = computed(() => {
.meta {
display: flex;
align-items: baseline;
gap: 5px;
gap: 9px;
}
.mk {
font-family: var(--mono);
font-size: 0.55rem;
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-faint);
color: var(--ink-dim);
}
.mv {
font-size: 0.8rem;
font-size: 0.88rem;
color: var(--ink);
}
.counts {
@ -125,9 +132,10 @@ const relTime = computed(() => {
font-weight: 700;
}
.ck {
font-size: 0.6rem;
font-size: 0.66rem;
text-transform: uppercase;
color: var(--ink-faint);
color: var(--ink-dim);
margin-left: 3px;
}
.dot {
color: var(--ink-faint);
@ -135,8 +143,8 @@ const relTime = computed(() => {
}
.ago {
font-family: var(--mono);
font-size: 0.62rem;
color: var(--ink-faint);
font-size: 0.7rem;
color: var(--ink-dim);
}
.belt-wrap {
display: flex;
@ -154,8 +162,8 @@ const relTime = computed(() => {
background: var(--ausschuss);
border: 0;
border-radius: var(--r);
padding: 12px 18px;
min-height: var(--size-touch-target, 44px);
padding: 8px 18px;
min-height: 36px;
cursor: pointer;
white-space: nowrap;
}

View File

@ -0,0 +1,195 @@
<!--
RejectPopover the booking form as a Popover anchored to the card's "Ausschuss
buchen" button (a tooltip-style panel with an arrow), NOT a centered modal.
Single instance owned by RejectStationPage: the page sets `entry` then calls
toggle(event) with the click that came from the card, so the panel points at the
clicked button. The reason is preselected to "Temperprozess nach Ofen" and its
Select is filterable typing e.g. "Ofen" narrows to the two Temperprozess reasons.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from 'primevue/popover'
import Select from 'primevue/select'
import InputNumber from 'primevue/inputnumber'
import Button from 'primevue/button'
import type { RejectEntry, Reason, RejectRequest } from '@/types/station'
const props = defineProps<{ entry: RejectEntry | null; reasons: Reason[]; submitting: boolean }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'submit', req: RejectRequest): void }>()
const { t, locale } = useI18n()
// The reason preselected each time the popover opens. Matched by German TEXT, not id
// (ids differ per deployment); degrades to "no selection" where this reason is absent
// (e.g. a deployment without it, or the generic dev sample set).
const DEFAULT_REASON_DE = 'Temperprozess nach Ofen'
const op = ref<InstanceType<typeof Popover> | null>(null)
const count = ref<number | null>(null)
const reasonId = ref<number | null>(null)
const touched = ref(false)
const reasonOptions = computed(() =>
props.reasons.map((r) => ({ id: r.id, label: locale.value === 'en' ? r.reason_en : r.reason_de })),
)
function defaultReasonId(): number | null {
const want = DEFAULT_REASON_DE.trim().toLowerCase()
const hit = props.reasons.find((r) => (r.reason_de ?? '').trim().toLowerCase() === want)
return hit ? hit.id : null
}
// Reset + preselect whenever a new entry opens the popover. `entry` always goes
// null entry on open (the page nulls it on close), so this fires every time.
watch(
() => props.entry,
(e) => {
if (e) {
count.value = null
reasonId.value = defaultReasonId()
touched.value = false
}
},
)
const valid = computed(() => (count.value ?? 0) > 0 && reasonId.value !== null)
function onBook() {
touched.value = true
if (!valid.value || !props.entry) return
emit('submit', {
press: props.entry.press,
orderNo: props.entry.orderNo,
ttId: props.entry.ttId,
count: count.value as number,
reason: reasonId.value as number,
})
}
// Imperative handle for the page: toggle anchored to the click event, or hide.
function toggle(ev: Event) {
op.value?.toggle(ev)
}
function hide() {
op.value?.hide()
}
defineExpose({ toggle, hide })
</script>
<template>
<Popover ref="op" :dismissable="!submitting" @hide="emit('close')">
<div v-if="entry" class="body">
<div class="head">
<span class="title">{{ t('dialog.title') }}</span>
<span class="for">{{ t('dialog.for', { cart: entry.cartId, order: entry.orderNo }) }}</span>
</div>
<label class="fld">
<span class="lab">{{ t('dialog.count') }} <em>({{ t('dialog.pieces') }})</em></span>
<InputNumber
v-model="count"
:min="1"
:max="entry.partsGood"
show-buttons
button-layout="horizontal"
:input-style="{ width: '4rem', textAlign: 'center' }"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
/>
<span v-if="touched && (count ?? 0) <= 0" class="err">{{ t('dialog.needCount') }}</span>
</label>
<label class="fld">
<span class="lab">{{ t('dialog.reason') }}</span>
<Select
v-model="reasonId"
:options="reasonOptions"
option-label="label"
option-value="id"
filter
auto-filter-focus
reset-filter-on-hide
:filter-placeholder="t('dialog.filterReason')"
:placeholder="t('dialog.chooseReason')"
/>
<span v-if="touched && reasonId === null" class="err">{{ t('dialog.needReason') }}</span>
</label>
<div class="actions">
<Button :label="t('dialog.cancel')" text :disabled="submitting" @click="hide" />
<Button
class="confirm"
:label="t('dialog.confirm')"
severity="danger"
:loading="submitting"
@click="onBook"
/>
</div>
</div>
</Popover>
</template>
<style scoped>
.body {
display: flex;
flex-direction: column;
gap: 14px;
width: min(22rem, 86vw);
padding: 4px 2px;
}
.head {
display: flex;
flex-direction: column;
gap: 3px;
}
.title {
font-size: 0.95rem;
font-weight: 800;
color: var(--ink);
}
.for {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-dim);
}
.fld {
display: flex;
flex-direction: column;
gap: 6px;
}
.lab {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink);
}
.lab em {
color: var(--ink-faint);
font-style: normal;
font-size: 0.72rem;
}
.err {
font-size: 0.72rem;
color: var(--ausschuss);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 2px;
}
/* Match the card's "Ausschuss buchen" button — burgundy, not PrimeVue's theme red. */
.confirm:deep(.p-button),
.confirm.p-button {
background: var(--ausschuss);
border-color: var(--ausschuss);
color: #fff;
}
.confirm:deep(.p-button:not(:disabled):hover),
.confirm.p-button:not(:disabled):hover {
background: var(--ausschuss);
border-color: var(--ausschuss);
filter: brightness(1.12);
}
</style>

View File

@ -19,6 +19,14 @@ const stations = computed(() => [
{ key: 'ofen', label: t('belt.ofen'), value: props.flow.ofen, color: 'var(--oven)' },
{ key: 'fertig', label: t('belt.fertig'), value: props.flow.fertig, color: 'var(--done)' },
])
// The ovendone link tracks the oven's own count only: once it's empty there's
// nothing left to hand off, regardless of how many carts already sit at fertig.
function isConvOn(i: number): boolean {
const cur = stations.value[i]
if (cur.key === 'ofen') return cur.value > 0
return cur.value > 0 || stations.value[i + 1].value > 0
}
</script>
<template>
@ -28,7 +36,7 @@ const stations = computed(() => [
<span class="badge num" :style="{ color: s.color, borderColor: s.color }">{{ s.value }}</span>
<span class="lbl">{{ s.label }}</span>
</div>
<span v-if="i < stations.length - 1" class="conv" :class="{ on: s.value > 0 || stations[i + 1].value > 0 }"></span>
<span v-if="i < stations.length - 1" class="conv" :class="{ on: isConvOn(i) }"></span>
</template>
</div>
</template>

View File

@ -7,13 +7,18 @@ const WINDOW_MINUTES: Record<WindowCode, number> = { '12h': 720, '1T': 1440, '7T
const PRESSES = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9']
// Includes the two "Temperprozess …" reasons so the dev/demo build exercises the
// popover's default-preselect ("Temperprozess nach Ofen") and the reason filter
// (typing "Ofen" narrows to both). Live reasons come from settings.reject_reasons.
export const SAMPLE_REASONS: Reason[] = [
{ id: 1, reason_de: 'Maßabweichung', reason_en: 'Dimensional deviation' },
{ id: 2, reason_de: 'Lunker', reason_en: 'Voids' },
{ id: 3, reason_de: 'Bindenaht', reason_en: 'Weld line' },
{ id: 4, reason_de: 'Verbrennung', reason_en: 'Burn mark' },
{ id: 5, reason_de: 'Verzug', reason_en: 'Warping' },
{ id: 6, reason_de: 'Sonstiges', reason_en: 'Other' },
{ id: 1, reason_de: 'Temperprozess vor Ofen', reason_en: 'Temper process before oven' },
{ id: 2, reason_de: 'Temperprozess nach Ofen', reason_en: 'Temper process after oven' },
{ id: 3, reason_de: 'Maßabweichung', reason_en: 'Dimensional deviation' },
{ id: 4, reason_de: 'Lunker', reason_en: 'Voids' },
{ id: 5, reason_de: 'Bindenaht', reason_en: 'Weld line' },
{ id: 6, reason_de: 'Verbrennung', reason_en: 'Burn mark' },
{ id: 7, reason_de: 'Verzug', reason_en: 'Warping' },
{ id: 8, reason_de: 'Sonstiges', reason_en: 'Other' },
]
const flow = (notYetSent: number, wagen: number, unterwegs: number, ofen: number, fertig: number): FlowDistribution => ({

View File

@ -7,7 +7,7 @@ export default {
demo: 'Demo-Daten',
},
filter: {
title: 'Pressen',
title: 'Maschinen',
all: 'Alle',
none: 'Keine',
empty: 'keine Wagen',
@ -44,6 +44,7 @@ export default {
pieces: 'Stück',
reason: 'Grund',
chooseReason: 'Grund wählen …',
filterReason: 'Grund suchen …',
cancel: 'Abbrechen',
confirm: 'Buchen',
needCount: 'Menge eingeben',

View File

@ -45,6 +45,7 @@ export default {
pieces: 'pcs',
reason: 'Reason',
chooseReason: 'Choose a reason …',
filterReason: 'Search reason …',
cancel: 'Cancel',
confirm: 'Book',
needCount: 'Enter a quantity',

View File

@ -28,7 +28,7 @@
--ink: #e8edf2;
--ink-dim: #97a3b1;
--ink-faint: #5a6573;
--ink-faint: #7f8b9a;
--press: #a78bfa; /* violet — Nicht gesendet */
--way: #22d3ee; /* cyan — Wagen */
@ -36,7 +36,7 @@
--oven: #f97316; /* orange — Ofen */
--hot: #ef4444; /* red — Ofen (peak) */
--done: #34d399; /* emerald — Fertig */
--ausschuss: #c4162a; /* dark-red — Ausschuss */
--ausschuss: #983048; /* burgundy — Ausschuss */
--o1: #5794f2;
--o2: #e8825a;
@ -59,7 +59,7 @@ html:not(.dark) {
--ink: #0f172a;
--ink-dim: #475569;
--ink-faint: #64748b;
--ink-faint: #4b586b;
--press: #6d28d9;
--way: #0e7490;
@ -67,7 +67,7 @@ html:not(.dark) {
--oven: #c2410c;
--hot: #dc2626;
--done: #15803d;
--ausschuss: #a11223;
--ausschuss: #86243c;
}
* {

View File

@ -2,7 +2,8 @@
RejectStationPage the central Ausschuss-Station terminal (design LOCKED):
left = press filter checkboxes (default all) + time-window picker in the header;
right = tempered carts (one row per ttId, newest first), each with the style-C
5-station temper belt and an "Ausschuss buchen" action booking dialog.
5-station temper belt and an "Ausschuss buchen" action booking popover
(a tooltip-style panel anchored to the button, not a centered modal).
Login-free by design. Data from the store (polled); no-backend falls back to
bundled sample data so `npm run dev` renders + the booking flow is demoable.
@ -14,7 +15,7 @@ import { useI18n } from 'vue-i18n'
import StationHeader from '@/components/StationHeader.vue'
import PressFilter from '@/components/PressFilter.vue'
import RejectEntryCard from '@/components/RejectEntryCard.vue'
import RejectDialog from '@/components/RejectDialog.vue'
import RejectPopover from '@/components/RejectPopover.vue'
import { useTemperRejectsStore } from '@/stores/temperRejects'
import type { RejectEntry, RejectRequest } from '@/types/station'
@ -22,6 +23,7 @@ const { t } = useI18n()
const store = useTemperRejectsStore()
const { window, reasons, allPresses, checkedPresses, pressCounts, filteredEntries, usingSample } = storeToRefs(store)
const pop = ref<InstanceType<typeof RejectPopover> | null>(null)
const bookingEntry = ref<RejectEntry | null>(null)
const submitting = ref(false)
const toast = ref<string | null>(null)
@ -35,15 +37,33 @@ function flashToast(msg: string, ok = true) {
toastTimer = setTimeout(() => (toast.value = null), 3200)
}
// Open the booking popover anchored to the card's button that was clicked. `ev` is
// the native click event forwarded by the card; `toggle` reads its target so the
// panel points at that button.
//
// If a popover is already open, this tap only dismisses it (PrimeVue's toggle hides
// on the same trigger, and an outside-click closes it too). We deliberately do NOT
// arm the new entry in that case otherwise its data would flash into the closing
// panel; a second tap opens the next card. A fresh tap anchors a new panel and, via
// the RejectPopover watcher, resets the form + preselects "Temperprozess nach Ofen".
function onBook(ev: Event, entry: RejectEntry) {
if (bookingEntry.value) {
pop.value?.hide()
return
}
bookingEntry.value = entry
pop.value?.toggle(ev)
}
async function onSubmit(req: RejectRequest) {
submitting.value = true
const res = await store.submitReject(req)
submitting.value = false
if (res.success) {
bookingEntry.value = null
pop.value?.hide() // @hide bookingEntry = null
flashToast(res.message || t('dialog.success'), true)
} else {
// keep the dialog open so the operator can retry a booking that did NOT go through
// keep the popover open so the operator can retry a booking that did NOT go through
flashToast(res.message || t('dialog.failed'), false)
}
}
@ -76,12 +96,12 @@ onUnmounted(() => {
<div v-if="checkedPresses.length === 0" class="state">{{ t('empty.noPress') }}</div>
<div v-else-if="filteredEntries.length === 0" class="state">{{ t('empty.noEntries') }}</div>
<div v-else class="list">
<RejectEntryCard v-for="e in filteredEntries" :key="e.ttId" :entry="e" @book="bookingEntry = $event" />
<RejectEntryCard v-for="e in filteredEntries" :key="e.ttId" :entry="e" @book="onBook($event, e)" />
</div>
</main>
</div>
<RejectDialog :entry="bookingEntry" :reasons="reasons" :submitting="submitting" @close="bookingEntry = null" @submit="onSubmit" />
<RejectPopover ref="pop" :entry="bookingEntry" :reasons="reasons" :submitting="submitting" @close="bookingEntry = null" @submit="onSubmit" />
<transition name="fade">
<div v-if="toast" class="toast" :class="{ err: !toastOk }">{{ toast }}</div>