Auto-saved at 2025-07-29 14:54:35 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
530 lines
22 KiB
TypeScript
530 lines
22 KiB
TypeScript
"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, 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, "") // Day name in first column, empty in second (for merging)
|
|
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"
|
|
]
|
|
|
|
// Add one more column to the right
|
|
titleRow.push("")
|
|
dayNameRow.push("")
|
|
yearRow.push("")
|
|
monthRow.push("")
|
|
dateRow.push("")
|
|
shiftRow.push("")
|
|
|
|
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) {
|
|
// Make first row (row 1) taller - 97px
|
|
for (let col = 0; col < (data[0]?.length || 0); col++) {
|
|
const colLetter = getExcelColumnName(col)
|
|
styles[`${colLetter}1`] = "height: 97px;"
|
|
}
|
|
|
|
// Make day names row (row 2) taller
|
|
for (let col = 0; col < (data[0]?.length || 0); col++) {
|
|
const colLetter = getExcelColumnName(col)
|
|
styles[`${colLetter}2`] = "height: 50px;"
|
|
}
|
|
|
|
// 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: normal;" // Yellow for day, not bold
|
|
} else { // Night shifts (even columns after first)
|
|
styles[`${colLetter}6`] = "background-color: #00b0f0; font-weight: normal;" // Blue for night, not bold
|
|
}
|
|
}
|
|
|
|
// Style weekend days (Saturday/Sunday in day name row and date rows below)
|
|
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)
|
|
|
|
// Weekend day name row (row 2) - merged cells
|
|
styles[`${colLetter}2`] = "background-color: #ffd966; height: 50px;" // Weekend day name with height
|
|
styles[`${nextColLetter}2`] = "background-color: #ffd966; height: 50px;"
|
|
|
|
// Weekend date columns (rows 3, 4, 5 - year, month, day)
|
|
for (let row = 3; row <= 5; row++) {
|
|
styles[`${colLetter}${row}`] = "background-color: #ffd966;" // Weekend date values
|
|
styles[`${nextColLetter}${row}`] = "background-color: #ffd966;" // Weekend date values
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove formatting specifically from BZ6 and CA6 cells
|
|
styles["BZ6"] = "" // Override any formatting for BZ6
|
|
styles["CA6"] = "" // Override any formatting for CA6
|
|
}
|
|
|
|
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 cell merges for header rows after initialization
|
|
setTimeout(() => {
|
|
if (jspreadsheetInstance.current && selectedMonth && selectedYear) {
|
|
const instance = jspreadsheetInstance.current as any
|
|
if (instance.setMerge) {
|
|
try {
|
|
// Merge all pairs in rows 1, 2, 3, 4, 5 (title, day names, year, month, day)
|
|
const totalCols = data[0]?.length || 0
|
|
for (let row = 1; row <= 5; row++) {
|
|
for (let col = 1; col < totalCols; col += 2) {
|
|
const colLetter = getExcelColumnName(col)
|
|
try {
|
|
// Correct syntax: setMerge(cellAddress, colspan, rowspan)
|
|
instance.setMerge(`${colLetter}${row}`, 2, 1) // 2 columns, 1 row
|
|
} catch (error) {
|
|
// Silently skip merge errors
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error during merging:", error)
|
|
}
|
|
}
|
|
}
|
|
}, 100)
|
|
|
|
// 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')
|
|
const totalCells = data[0]?.length || 0
|
|
|
|
// Rotate data values in rows 2, 3, 4, 5, 6 (day names, year, month, day, shifts)
|
|
;[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 && cellIndex < totalCells + 1) { // +1 because of row number column
|
|
const originalText = cell.textContent?.trim()
|
|
if (originalText) {
|
|
// Use 12px font for all rotated values (date values, day names, and shifts)
|
|
cell.innerHTML = `<div style="transform: rotate(-90deg); font-size: 12px; 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")
|
|
}
|
|
}
|
|
|
|
|
|
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>
|
|
)
|
|
}
|