Initial commit: TKB Timeshift Schedule Management Application
- Next.js 15.2.4 with React 19 and TypeScript - Monthly schedule generator with Excel-like formatting - Complete employee list from example.xlsx (18 employees) - Excel-style column naming (A, B, C, ..., Z, AA, AB, ...) - Counter-clockwise text rotation for data values - Weekend highlighting and shift color coding - Team selection for TKB, METRO, D8 - 7 days from previous month + full current month date range - jspreadsheet integration for Excel-like interface 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
472
components/timeshift-spreadsheet.tsx
Normal file
472
components/timeshift-spreadsheet.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Save, Download, Plus } from "lucide-react"
|
||||
|
||||
interface TimeshiftSpreadsheetProps {
|
||||
teamId: string
|
||||
teamName: string
|
||||
}
|
||||
|
||||
export function TimeshiftSpreadsheet({ teamId: _teamId, teamName }: TimeshiftSpreadsheetProps) {
|
||||
const spreadsheetRef = React.useRef<HTMLDivElement>(null)
|
||||
const jspreadsheetInstance = React.useRef<unknown>(null)
|
||||
const currentDate = new Date()
|
||||
const [selectedMonth, setSelectedMonth] = React.useState<string>((currentDate.getMonth() + 1).toString())
|
||||
const [selectedYear, setSelectedYear] = React.useState<string>(currentDate.getFullYear().toString())
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||
const [forceUpdate, setForceUpdate] = React.useState(0)
|
||||
|
||||
// Function to generate Excel-style column names (A, B, C, ..., Z, AA, AB, ...)
|
||||
const getExcelColumnName = (index: number): string => {
|
||||
let result = ''
|
||||
while (index >= 0) {
|
||||
result = String.fromCharCode(65 + (index % 26)) + result
|
||||
index = Math.floor(index / 26) - 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Generate monthly schedule data
|
||||
const generateMonthlyData = (month: number, year: number) => {
|
||||
console.log("Generating data for month:", month, "year:", year)
|
||||
// Get first and last day of selected month
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const lastDay = new Date(year, month, 0)
|
||||
console.log("Date range:", firstDay, "to", lastDay)
|
||||
|
||||
// Get exactly 7 days from previous month
|
||||
const prevMonthStart = new Date(firstDay)
|
||||
prevMonthStart.setDate(firstDay.getDate() - 7)
|
||||
|
||||
// End at the last day of the selected month (no next month days)
|
||||
const nextMonthEnd = new Date(lastDay)
|
||||
|
||||
console.log("Full date range:", prevMonthStart.toDateString(), "to", nextMonthEnd.toDateString())
|
||||
console.log("Previous month start:", prevMonthStart.toDateString(), "Current month end:", nextMonthEnd.toDateString())
|
||||
|
||||
// Generate header rows
|
||||
const titleRow = [`${String(month).padStart(2, '0')}.${year} v.1`, "Uzávěry ostatní"]
|
||||
const dayNameRow = [""]
|
||||
const yearRow = ["rok"]
|
||||
const monthRow = ["měsíc"]
|
||||
const dateRow = ["den"]
|
||||
const shiftRow = [`Pohotovost ${_teamId.toUpperCase()}`]
|
||||
|
||||
// Generate columns for each day from prevMonthStart to nextMonthEnd
|
||||
const currentDate = new Date(prevMonthStart)
|
||||
let dayCount = 0
|
||||
const maxDays = 50 // Safety limit to prevent infinite loops
|
||||
|
||||
console.log("Starting date loop from:", currentDate.toDateString(), "to:", nextMonthEnd.toDateString())
|
||||
|
||||
while (currentDate <= nextMonthEnd && dayCount < maxDays) {
|
||||
const dayName = ["Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota"][currentDate.getDay()]
|
||||
|
||||
titleRow.push("", "")
|
||||
dayNameRow.push(dayName, "")
|
||||
yearRow.push(currentDate.getFullYear().toString(), "")
|
||||
monthRow.push((currentDate.getMonth() + 1).toString(), "")
|
||||
dateRow.push(currentDate.getDate().toString(), "")
|
||||
shiftRow.push("den", "noc")
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1)
|
||||
dayCount++
|
||||
}
|
||||
|
||||
console.log("Generated", dayCount, "days of data")
|
||||
|
||||
// Complete employee data from Excel file
|
||||
const employees = [
|
||||
"Pauzer Libor (all in one)",
|
||||
"Vörös Pavel (NN)",
|
||||
"Janouš Petr (VN)",
|
||||
"Dvořák Václav (VN)",
|
||||
"Vondrák Pavel (NN)",
|
||||
"Čeleda Olda (NN)",
|
||||
"Hanzlík Marek (VN)",
|
||||
"Kohl David (VN)",
|
||||
"Dittrich Vladimír (VN)",
|
||||
"Toman Milan (VN)",
|
||||
"Glaser Ondřej (NN)",
|
||||
"Herbst David (NN)",
|
||||
"Ryba Ondřej (NN)",
|
||||
"Zábranský Petr (NN)",
|
||||
"Žemlička Miroslav (NN)",
|
||||
"Teslík Hynek (NN)",
|
||||
"X BEZ zkušeností s VN",
|
||||
"Pohotovost IT"
|
||||
]
|
||||
|
||||
const employeeRows = employees.map(name => {
|
||||
const row = [name]
|
||||
// Add empty cells for each day/night pair
|
||||
for (let i = 1; i < shiftRow.length; i++) {
|
||||
row.push("")
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
const result = [titleRow, dayNameRow, yearRow, monthRow, dateRow, shiftRow, [""], ...employeeRows]
|
||||
console.log("Generated", result.length, "rows with", result[0]?.length, "columns")
|
||||
console.log("Title row:", titleRow.slice(0, 10))
|
||||
console.log("Day row:", dayNameRow.slice(0, 10))
|
||||
return result
|
||||
}
|
||||
|
||||
// Sample data for different teams (fallback)
|
||||
const getTeamData = (_teamId: string) => {
|
||||
const baseData = [
|
||||
["Employee", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
||||
["John Smith", "08:00-16:00", "08:00-16:00", "08:00-16:00", "08:00-16:00", "08:00-16:00", "OFF", "OFF"],
|
||||
["Jane Doe", "16:00-00:00", "16:00-00:00", "16:00-00:00", "16:00-00:00", "16:00-00:00", "OFF", "OFF"],
|
||||
["Mike Johnson", "00:00-08:00", "00:00-08:00", "00:00-08:00", "00:00-08:00", "00:00-08:00", "OFF", "OFF"],
|
||||
["Sarah Wilson", "08:00-16:00", "OFF", "08:00-16:00", "OFF", "08:00-16:00", "08:00-16:00", "08:00-16:00"],
|
||||
["", "", "", "", "", "", "", ""],
|
||||
["", "", "", "", "", "", "", ""],
|
||||
]
|
||||
return baseData
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log("useEffect triggered - Month:", selectedMonth, "Year:", selectedYear, "Force:", forceUpdate)
|
||||
const loadJSpreadsheet = async () => {
|
||||
console.log("Starting loadJSpreadsheet function")
|
||||
|
||||
// Check if scripts are already loaded
|
||||
const windowWithJExcel = window as unknown as { jexcel?: unknown, jspreadsheet?: unknown }
|
||||
|
||||
if (!windowWithJExcel.jexcel && !windowWithJExcel.jspreadsheet) {
|
||||
console.log("Loading jspreadsheet scripts...")
|
||||
|
||||
// Load CSS if not already loaded
|
||||
if (!document.querySelector('link[href*="jexcel.css"]')) {
|
||||
const cssLink = document.createElement("link")
|
||||
cssLink.rel = "stylesheet"
|
||||
cssLink.href = "https://bossanova.uk/jspreadsheet/v4/jexcel.css"
|
||||
document.head.appendChild(cssLink)
|
||||
}
|
||||
|
||||
if (!document.querySelector('link[href*="jsuites.css"]')) {
|
||||
const jSuiteCssLink = document.createElement("link")
|
||||
jSuiteCssLink.rel = "stylesheet"
|
||||
jSuiteCssLink.href = "https://jsuites.net/v4/jsuites.css"
|
||||
document.head.appendChild(jSuiteCssLink)
|
||||
}
|
||||
|
||||
// Load JavaScript files if not already loaded
|
||||
if (!document.querySelector('script[src*="jsuites.js"]')) {
|
||||
const jSuitesScript = document.createElement("script")
|
||||
jSuitesScript.src = "https://jsuites.net/v4/jsuites.js"
|
||||
document.head.appendChild(jSuitesScript)
|
||||
}
|
||||
|
||||
if (!document.querySelector('script[src*="jexcel.js"]')) {
|
||||
const jSpreadsheetScript = document.createElement("script")
|
||||
jSpreadsheetScript.src = "https://bossanova.uk/jspreadsheet/v4/jexcel.js"
|
||||
document.head.appendChild(jSpreadsheetScript)
|
||||
|
||||
// Wait for scripts to load
|
||||
console.log("Waiting for scripts to load...")
|
||||
await new Promise((resolve) => {
|
||||
jSpreadsheetScript.onload = () => {
|
||||
console.log("Scripts loaded successfully")
|
||||
setTimeout(resolve, 200) // Longer delay
|
||||
}
|
||||
jSpreadsheetScript.onerror = () => {
|
||||
console.error("Failed to load jspreadsheet script")
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log("Scripts already loaded")
|
||||
}
|
||||
|
||||
// Initialize spreadsheet
|
||||
const windowObj = window as unknown as { jexcel?: unknown, jspreadsheet?: unknown }
|
||||
const jexcelLib = windowObj.jexcel || windowObj.jspreadsheet
|
||||
console.log("Checking for jexcel availability:", !!windowObj.jexcel)
|
||||
console.log("Checking for jspreadsheet availability:", !!windowObj.jspreadsheet)
|
||||
console.log("Using library:", jexcelLib ? (windowObj.jexcel ? 'jexcel' : 'jspreadsheet') : 'none')
|
||||
console.log("Checking spreadsheet ref:", !!spreadsheetRef.current)
|
||||
|
||||
if (spreadsheetRef.current && jexcelLib) {
|
||||
console.log("Initializing spreadsheet...")
|
||||
// Clear previous instance
|
||||
if (jspreadsheetInstance.current) {
|
||||
console.log("Destroying previous instance")
|
||||
jspreadsheetInstance.current.destroy()
|
||||
}
|
||||
|
||||
// Use monthly data if month/year selected, otherwise use basic data
|
||||
let data
|
||||
try {
|
||||
if (selectedMonth && selectedYear) {
|
||||
console.log("About to generate monthly data for:", parseInt(selectedMonth), parseInt(selectedYear))
|
||||
data = generateMonthlyData(parseInt(selectedMonth), parseInt(selectedYear))
|
||||
console.log("Monthly data generated successfully")
|
||||
} else {
|
||||
console.log("Using basic team data")
|
||||
data = getTeamData(_teamId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating data:", error)
|
||||
data = getTeamData(_teamId)
|
||||
}
|
||||
|
||||
console.log("Data generated:", data.length, "rows, first row:", data[0]?.length, "columns")
|
||||
console.log("First few rows:", data.slice(0, 3))
|
||||
|
||||
// Generate column configuration based on data
|
||||
const columns = data[0]?.map((_, index) => ({
|
||||
type: "text",
|
||||
title: getExcelColumnName(index), // A, B, C, ..., Z, AA, AB, ...
|
||||
width: index === 0 ? 200 : 25 // First column 200px, all others 25px
|
||||
})) || []
|
||||
|
||||
// Generate styles for day/night shifts based on the Excel pattern
|
||||
const styles: Record<string, string> = {}
|
||||
if (selectedMonth && selectedYear) {
|
||||
// Style header rows
|
||||
for (let col = 1; col < (data[0]?.length || 0); col++) {
|
||||
const colLetter = getExcelColumnName(col)
|
||||
if (col % 2 === 1) { // Day shifts (odd columns after first)
|
||||
styles[`${colLetter}6`] = "background-color: #ffff00; font-weight: bold;" // Yellow for day
|
||||
} else { // Night shifts (even columns after first)
|
||||
styles[`${colLetter}6`] = "background-color: #00b0f0; font-weight: bold;" // Blue for night
|
||||
}
|
||||
}
|
||||
|
||||
// Style weekend days (Saturday/Sunday in day name row)
|
||||
for (let col = 1; col < (data[0]?.length || 0); col += 2) {
|
||||
const dayName = data[1]?.[col]
|
||||
if (dayName === "Sobota" || dayName === "Neděle") {
|
||||
const colLetter = getExcelColumnName(col)
|
||||
const nextColLetter = getExcelColumnName(col + 1)
|
||||
styles[`${colLetter}2`] = "background-color: #ffd966;" // Weekend day name
|
||||
styles[`${nextColLetter}2`] = "background-color: #ffd966;"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Initializing jspreadsheet with config:", {
|
||||
dataRows: data.length,
|
||||
dataCols: data[0]?.length,
|
||||
columns: columns.length,
|
||||
stylesCount: Object.keys(styles).length
|
||||
})
|
||||
|
||||
jspreadsheetInstance.current = (jexcelLib as (el: HTMLElement, config: unknown) => unknown)(spreadsheetRef.current, {
|
||||
data: data,
|
||||
columns: columns,
|
||||
minDimensions: [data[0]?.length || 8, Math.max(data.length + 3, 10)],
|
||||
allowInsertRow: true,
|
||||
allowInsertColumn: false,
|
||||
allowDeleteRow: true,
|
||||
allowDeleteColumn: false,
|
||||
contextMenu: true,
|
||||
tableOverflow: true,
|
||||
tableWidth: "100%",
|
||||
tableHeight: "500px",
|
||||
style: styles,
|
||||
})
|
||||
|
||||
console.log("jspreadsheet initialized:", !!jspreadsheetInstance.current)
|
||||
|
||||
// Apply counter-clockwise rotation to data values only (exclude second column which contains field labels)
|
||||
setTimeout(() => {
|
||||
const table = spreadsheetRef.current?.querySelector('.jexcel tbody')
|
||||
if (table) {
|
||||
const rows = table.querySelectorAll('tr')
|
||||
|
||||
// Rotate data values in rows 2, 3, 4, 5, 6 (day names, year, month, day, shifts) excluding first two columns
|
||||
;[1, 2, 3, 4, 5].forEach(rowIndex => {
|
||||
if (rows[rowIndex]) {
|
||||
const cells = rows[rowIndex].querySelectorAll('td')
|
||||
cells.forEach((cell, cellIndex) => {
|
||||
// Skip first two columns (row numbers and field labels)
|
||||
if (cellIndex > 1) {
|
||||
const originalText = cell.textContent
|
||||
cell.innerHTML = `<div style="transform: rotate(-90deg); font-size: 9px; height: 20px; display: flex; align-items: center; justify-content: center; white-space: nowrap;">${originalText}</div>`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Make only "Pohotovost TKB" bold, keep other field labels regular and ensure they're not rotated
|
||||
;[0, 2, 3, 4, 5].forEach(rowIndex => {
|
||||
if (rows[rowIndex]) {
|
||||
const cells = rows[rowIndex].querySelectorAll('td')
|
||||
if (cells[1]) { // Second column (index 1)
|
||||
cells[1].style.transform = 'none'
|
||||
// Only make "Pohotovost TKB" bold (row index 5)
|
||||
if (rowIndex === 5) {
|
||||
cells[1].style.fontWeight = 'bold'
|
||||
} else {
|
||||
cells[1].style.fontWeight = 'normal'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 200)
|
||||
|
||||
} else {
|
||||
console.log("Cannot initialize - missing ref or jexcel library")
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Calling loadJSpreadsheet...")
|
||||
loadJSpreadsheet().catch(error => {
|
||||
console.error("Error in loadJSpreadsheet:", error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (jspreadsheetInstance.current) {
|
||||
jspreadsheetInstance.current.destroy()
|
||||
}
|
||||
}
|
||||
}, [_teamId, selectedMonth, selectedYear, forceUpdate])
|
||||
|
||||
const handleSave = () => {
|
||||
if (jspreadsheetInstance.current) {
|
||||
const data = jspreadsheetInstance.current.getData()
|
||||
console.log("Saving data:", data)
|
||||
// Here you would typically save to your backend
|
||||
alert("Schedule saved successfully!")
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
if (jspreadsheetInstance.current) {
|
||||
jspreadsheetInstance.current.download()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateSchedule = () => {
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleGenerateSchedule = () => {
|
||||
console.log("Generate clicked - Month:", selectedMonth, "Year:", selectedYear)
|
||||
if (selectedMonth && selectedYear) {
|
||||
setDialogOpen(false)
|
||||
console.log("Generating schedule for:", selectedMonth, selectedYear)
|
||||
// Force useEffect to trigger by updating the forceUpdate counter
|
||||
setForceUpdate(prev => prev + 1)
|
||||
} else {
|
||||
console.log("Month or year not selected")
|
||||
alert("Please select both month and year")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddEmployee = () => {
|
||||
if (jspreadsheetInstance.current) {
|
||||
jspreadsheetInstance.current.insertRow()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">{teamName} - Weekly Schedule</CardTitle>
|
||||
<CardDescription>Manage work shifts and schedules for your team members</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleCreateSchedule} variant="outline" size="sm">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Create
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Monthly Schedule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the month and year for which you want to generate the timetable.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="month" className="text-right">
|
||||
Month
|
||||
</label>
|
||||
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">January</SelectItem>
|
||||
<SelectItem value="2">February</SelectItem>
|
||||
<SelectItem value="3">March</SelectItem>
|
||||
<SelectItem value="4">April</SelectItem>
|
||||
<SelectItem value="5">May</SelectItem>
|
||||
<SelectItem value="6">June</SelectItem>
|
||||
<SelectItem value="7">July</SelectItem>
|
||||
<SelectItem value="8">August</SelectItem>
|
||||
<SelectItem value="9">September</SelectItem>
|
||||
<SelectItem value="10">October</SelectItem>
|
||||
<SelectItem value="11">November</SelectItem>
|
||||
<SelectItem value="12">December</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="year" className="text-right">
|
||||
Year
|
||||
</label>
|
||||
<Select value={selectedYear} onValueChange={setSelectedYear}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2024">2024</SelectItem>
|
||||
<SelectItem value="2025">2025</SelectItem>
|
||||
<SelectItem value="2026">2026</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={handleGenerateSchedule}>Generate Schedule</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button onClick={handleSave} variant="outline" size="sm">
|
||||
<Save className="size-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={handleExport} variant="outline" size="sm">
|
||||
<Download className="size-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div ref={spreadsheetRef} className="w-full" />
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Instructions:</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Click on any cell to edit shift times (e.g., "08:00-16:00")</li>
|
||||
<li>Use "OFF" for days off</li>
|
||||
<li>Right-click for context menu options</li>
|
||||
<li>Use the "Add Employee" button to add new team members</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user