Tech Tools section with a code generator, toner report that names the part, toner forecasting, warranty proof of cover, application uploads
This commit is contained in:
@@ -306,6 +306,9 @@ export const printersApi = {
|
||||
getDrivers(id) {
|
||||
return api.get(`/printers/${id}/drivers`)
|
||||
},
|
||||
supplyForecast(days = 90) {
|
||||
return api.get('/printers/supplies/forecast', { params: { days } })
|
||||
},
|
||||
lowSupplies() {
|
||||
return api.get('/printers/lowsupplies')
|
||||
},
|
||||
@@ -477,6 +480,33 @@ export const applicationsApi = {
|
||||
delete(id) {
|
||||
return api.delete(`/applications/${id}`)
|
||||
},
|
||||
// multipart image upload; backend sets image to the served URL
|
||||
uploadImage(id, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removeImage(id) {
|
||||
return api.delete(`/applications/${id}/image`)
|
||||
},
|
||||
// installer upload; backend sets installpath to the download URL.
|
||||
// onProgress gets 0-100 - an installer is big enough that a silent wait
|
||||
// reads as a hang.
|
||||
uploadPackage(id, file, onProgress) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/applications/${id}/package`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: event => {
|
||||
if (onProgress && event.total) {
|
||||
onProgress(Math.round((event.loaded * 100) / event.total))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
removePackage(id) {
|
||||
return api.delete(`/applications/${id}/package`)
|
||||
},
|
||||
// Versions
|
||||
getVersions(appId) {
|
||||
return api.get(`/applications/${appId}/versions`)
|
||||
@@ -1088,6 +1118,15 @@ export const warrantyApi = {
|
||||
},
|
||||
report() {
|
||||
return api.get('/warranty/report')
|
||||
},
|
||||
// proof of cover: invoice, certificate, whatever the vendor sent
|
||||
uploadProof(id, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removeProof(id) {
|
||||
return api.delete(`/warranty/${id}/proof`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,16 +120,27 @@ defineExpose({ load })
|
||||
<style scoped>
|
||||
.dc-grid {
|
||||
display: grid;
|
||||
/* Four across on a wide screen, not five. A card holds a hostname, a machine
|
||||
number and a state on one line; at five columns those lines truncate on
|
||||
the very rows that matter most. auto-fit rather than auto-fill so two
|
||||
cards fill the width instead of leaving three empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
|
||||
/* Four across on a wide screen. A card holds a hostname, a machine number
|
||||
and a state on one line, so the track cannot go much below this without
|
||||
truncating the rows that matter most - but 28rem only fits three once the
|
||||
sidebar takes its share at 1920, which is the common case here. auto-fit
|
||||
rather than auto-fill so two cards fill the width instead of leaving
|
||||
empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
max-width: 120rem;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
||||
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
||||
the cards get narrower and the rows they hold start truncating again. */
|
||||
@media (min-width: 96rem) {
|
||||
.dc-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.dc-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,268 +1,270 @@
|
||||
<template>
|
||||
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && hasPosition"
|
||||
class="map-tooltip"
|
||||
:style="tooltipStyle"
|
||||
ref="tooltipRef"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div class="map-tooltip-content">
|
||||
<div class="map-preview" ref="mapPreview">
|
||||
<div
|
||||
class="map-transform"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<img
|
||||
:src="blueprintUrl"
|
||||
alt="Shop Floor Map"
|
||||
class="map-image"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<!-- Marker dot -->
|
||||
<div
|
||||
class="marker-dot"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-tooltip-footer">
|
||||
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||
<span class="zoom-hint">Scroll to zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||
|
||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||
// when it loads.
|
||||
loadMapConfig()
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const mapPreview = ref(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isOverTooltip = ref(false)
|
||||
const zoom = ref(1)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
return props.left !== null && props.top !== null
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 100
|
||||
})
|
||||
|
||||
// Marker style with counter-scale to maintain constant size
|
||||
const markerStyle = computed(() => ({
|
||||
left: markerX.value + '%',
|
||||
top: markerY.value + '%',
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||
}))
|
||||
|
||||
// Transform style that centers on the marker and zooms toward it
|
||||
const transformStyle = computed(() => {
|
||||
// Calculate translation to center the marker in the preview
|
||||
const translateX = 50 - markerX.value
|
||||
const translateY = 50 - markerY.value
|
||||
|
||||
return {
|
||||
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x}px`,
|
||||
top: `${tooltipPosition.value.y}px`
|
||||
}))
|
||||
|
||||
function onImageLoad() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
function showTooltip(event) {
|
||||
if (!hasPosition.value) return
|
||||
|
||||
visible.value = true
|
||||
zoom.value = 1
|
||||
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
adjustPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function onWrapperLeave() {
|
||||
// Small delay to allow moving to tooltip
|
||||
setTimeout(() => {
|
||||
if (!isOverTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function onTooltipEnter() {
|
||||
isOverTooltip.value = true
|
||||
}
|
||||
|
||||
function onTooltipLeave() {
|
||||
isOverTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible.value = false
|
||||
zoom.value = 1
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||
zoom.value = newZoom
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!tooltipRef.value) return
|
||||
|
||||
const tooltip = tooltipRef.value
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (rect.right > viewportWidth - 20) {
|
||||
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||
}
|
||||
if (rect.left < 20) {
|
||||
tooltipPosition.value.x += (20 - rect.left)
|
||||
}
|
||||
|
||||
if (rect.bottom > viewportHeight - 20) {
|
||||
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when tooltip becomes visible
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
zoom.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
// Reset imageLoaded when theme changes to force reload
|
||||
watch(currentTheme, () => {
|
||||
imageLoaded.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.location-tooltip-wrapper {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-tooltip-wrapper:hover {
|
||||
color: var(--primary, #1976d2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.map-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
height: 385px;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg, #f5f5f5);
|
||||
border-top: 1px solid var(--border, #e0e0e0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && hasPosition"
|
||||
class="map-tooltip"
|
||||
:style="tooltipStyle"
|
||||
ref="tooltipRef"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div class="map-tooltip-content">
|
||||
<div class="map-preview" ref="mapPreview">
|
||||
<div
|
||||
class="map-transform"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<img
|
||||
:src="blueprintUrl"
|
||||
alt="Shop Floor Map"
|
||||
class="map-image"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<!-- Marker dot -->
|
||||
<div
|
||||
class="marker-dot"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-tooltip-footer">
|
||||
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||
<span class="zoom-hint">Scroll to zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||
|
||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||
// when it loads.
|
||||
loadMapConfig()
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const mapPreview = ref(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isOverTooltip = ref(false)
|
||||
const zoom = ref(1)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
return props.left !== null && props.top !== null
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 100
|
||||
})
|
||||
|
||||
// Marker style with counter-scale to maintain constant size
|
||||
const markerStyle = computed(() => ({
|
||||
left: markerX.value + '%',
|
||||
top: markerY.value + '%',
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||
}))
|
||||
|
||||
// Transform style that centers on the marker and zooms toward it
|
||||
const transformStyle = computed(() => {
|
||||
// Calculate translation to center the marker in the preview
|
||||
const translateX = 50 - markerX.value
|
||||
const translateY = 50 - markerY.value
|
||||
|
||||
return {
|
||||
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x}px`,
|
||||
top: `${tooltipPosition.value.y}px`
|
||||
}))
|
||||
|
||||
function onImageLoad() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
function showTooltip(event) {
|
||||
if (!hasPosition.value) return
|
||||
|
||||
visible.value = true
|
||||
zoom.value = 1
|
||||
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
adjustPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function onWrapperLeave() {
|
||||
// Small delay to allow moving to tooltip
|
||||
setTimeout(() => {
|
||||
if (!isOverTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function onTooltipEnter() {
|
||||
isOverTooltip.value = true
|
||||
}
|
||||
|
||||
function onTooltipLeave() {
|
||||
isOverTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible.value = false
|
||||
zoom.value = 1
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||
zoom.value = newZoom
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!tooltipRef.value) return
|
||||
|
||||
const tooltip = tooltipRef.value
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (rect.right > viewportWidth - 20) {
|
||||
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||
}
|
||||
if (rect.left < 20) {
|
||||
tooltipPosition.value.x += (20 - rect.left)
|
||||
}
|
||||
|
||||
if (rect.bottom > viewportHeight - 20) {
|
||||
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when tooltip becomes visible
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
zoom.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
// Reset imageLoaded when theme changes to force reload
|
||||
watch(currentTheme, () => {
|
||||
imageLoaded.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.location-tooltip-wrapper {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-tooltip-wrapper:hover {
|
||||
color: var(--primary, #1976d2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.map-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
position: relative;
|
||||
/* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
|
||||
covered the row it was launched from. Aspect ratio kept. */
|
||||
width: 390px;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg, #f5f5f5);
|
||||
border-top: 1px solid var(--border, #e0e0e0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
</style>
|
||||
|
||||
48
frontend/src/utils/fiscalWeek.js
Normal file
48
frontend/src/utils/fiscalWeek.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Fiscal week, as the classic ASP site computed it.
|
||||
*
|
||||
* That site called it "Fiscal Week" and calculated an ISO 8601 week number
|
||||
* (includes/leftsidebar.asp): week 1 is the week containing 4 January, weeks
|
||||
* start on Monday, and the year is decided by that week's Thursday. This is a
|
||||
* faithful port - the label and the arithmetic both come from there.
|
||||
*
|
||||
* Worth knowing if the numbers are ever questioned: a true GE fiscal calendar
|
||||
* need not line up with ISO weeks. Nobody has asked for a different rule, and
|
||||
* changing it here would silently disagree with the classic site people still
|
||||
* compare against, so it stays ISO until someone says otherwise.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ISO 8601 week number for a date (1-53).
|
||||
*
|
||||
* Works in local time deliberately: the number people quote is the one on the
|
||||
* wall where they are, and a UTC-based week rolls over hours early in the
|
||||
* evening at a US site.
|
||||
*/
|
||||
export function fiscalWeek(date = new Date()) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
// The Thursday of this week decides which year the week belongs to, which is
|
||||
// what makes late December and early January land in the right year.
|
||||
const dayOfWeek = (day.getDay() + 6) % 7 // 0 = Monday
|
||||
const thursday = new Date(day)
|
||||
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||
|
||||
// Week 1 is the week containing 4 January; find its Monday.
|
||||
const jan4 = new Date(thursday.getFullYear(), 0, 4)
|
||||
const jan4DayOfWeek = (jan4.getDay() + 6) % 7
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek)
|
||||
|
||||
const days = Math.round((thursday - week1Monday) / 86400000)
|
||||
return Math.floor(days / 7) + 1
|
||||
}
|
||||
|
||||
/** The year the week belongs to, which is not always the calendar year. */
|
||||
export function fiscalWeekYear(date = new Date()) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const dayOfWeek = (day.getDay() + 6) % 7
|
||||
const thursday = new Date(day)
|
||||
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||
return thursday.getFullYear()
|
||||
}
|
||||
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fiscalWeek, fiscalWeekYear } from './fiscalWeek'
|
||||
|
||||
/**
|
||||
* The number has to match what the classic ASP site shows, because people will
|
||||
* have both open. The awkward cases are all at the year boundary, which is
|
||||
* exactly where a naive "day of year / 7" gets it wrong.
|
||||
*/
|
||||
describe('fiscalWeek', () => {
|
||||
it('counts the week containing 4 January as week 1', () => {
|
||||
expect(fiscalWeek(new Date(2026, 0, 4))).toBe(1)
|
||||
})
|
||||
|
||||
it('puts a late-December date in week 1 of the NEXT year', () => {
|
||||
// 2025-12-29 is a Monday; its Thursday falls in 2026, so it is 2026 week 1.
|
||||
expect(fiscalWeek(new Date(2025, 11, 29))).toBe(1)
|
||||
expect(fiscalWeekYear(new Date(2025, 11, 29))).toBe(2026)
|
||||
})
|
||||
|
||||
it('puts an early-January date in the LAST week of the previous year', () => {
|
||||
// 2027-01-01 is a Friday; its Thursday is 2026-12-31, so it is 2026 week 53.
|
||||
expect(fiscalWeek(new Date(2027, 0, 1))).toBe(53)
|
||||
expect(fiscalWeekYear(new Date(2027, 0, 1))).toBe(2026)
|
||||
})
|
||||
|
||||
it('holds the same number all week, Monday through Sunday', () => {
|
||||
const monday = new Date(2026, 7, 10)
|
||||
const week = fiscalWeek(monday)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(2026, 7, 10 + i)
|
||||
expect(fiscalWeek(day)).toBe(week)
|
||||
}
|
||||
})
|
||||
|
||||
it('increments the following Monday', () => {
|
||||
expect(fiscalWeek(new Date(2026, 7, 17))).toBe(fiscalWeek(new Date(2026, 7, 10)) + 1)
|
||||
})
|
||||
|
||||
it('gives a 53-week year its 53rd week', () => {
|
||||
// 2026 starts on a Thursday, so it runs to 53 weeks.
|
||||
expect(fiscalWeek(new Date(2026, 11, 31))).toBe(53)
|
||||
})
|
||||
|
||||
it('is not fooled by a time of day', () => {
|
||||
const morning = new Date(2026, 7, 12, 6, 0, 0)
|
||||
const nearMidnight = new Date(2026, 7, 12, 23, 59, 0)
|
||||
expect(fiscalWeek(morning)).toBe(fiscalWeek(nearMidnight))
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut
|
||||
Box, KeyRound, LogOut, Wrench
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -166,6 +166,7 @@ const iconMap = {
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
'wrench': Wrench,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
Reference in New Issue
Block a user