feat(calendar): add calendar.css grid layout + styled day cells and legend
- Create calendar.css with 7-column grid using display: grid - Style calendar header with navigation buttons (min 36x36px tap targets) - Style day cells with borders, hover states, and focus-visible outlines - Style weekday headers (centered, muted, uppercase) - Create --disabled modifier for leading/trailing days from adjacent months - Create --has-events modifier for visual emphasis - Create event markers as small dots (8x8px circles) - Style legend with compact horizontal swatch layout - Import CSS into Calendar.tsx following co-located pattern from Header Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,11 +6,17 @@ import { Layout } from '@/components/layout/Layout'
|
|||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { CalendarDay } from '@/components/common/CalendarDay'
|
import { CalendarDay } from '@/components/common/CalendarDay'
|
||||||
import { CalendarEvent } from '@/components/common/CalendarEvent'
|
import { CalendarEvent } from '@/components/common/CalendarEvent'
|
||||||
|
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||||
|
import './calendar.css'
|
||||||
|
|
||||||
export function Calendar(): ReactNode {
|
export function Calendar(): ReactNode {
|
||||||
const { isLoading: wsLoading, selectedChildId } = useWorkspace()
|
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid } = useWorkspace()
|
||||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
const { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined)
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date(2026, 5))
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null)
|
||||||
|
|
||||||
const isLoading = wsLoading || pdLoading
|
const isLoading = wsLoading || pdLoading
|
||||||
|
|
||||||
@@ -24,13 +30,13 @@ export function Calendar(): ReactNode {
|
|||||||
const firstDay = new Date(year, month, 1)
|
const firstDay = new Date(year, month, 1)
|
||||||
const lastDay = new Date(year, month + 1, 0)
|
const lastDay = new Date(year, month + 1, 0)
|
||||||
const daysInMonth = lastDay.getDate()
|
const daysInMonth = lastDay.getDate()
|
||||||
const startingDayOfWeek = firstDay.getDay()
|
const startOffset = (firstDay.getDay() + 6) % 7
|
||||||
|
|
||||||
const weeks: string[][] = []
|
const weeks: string[][] = []
|
||||||
let currentWeek: string[] = []
|
let currentWeek: string[] = []
|
||||||
|
|
||||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
for (let i = 0; i < startOffset; i++) {
|
||||||
const prevDate = new Date(year, month, -startingDayOfWeek + i + 1)
|
const prevDate = new Date(year, month, -startOffset + i + 1)
|
||||||
currentWeek.push(formatDate(prevDate))
|
currentWeek.push(formatDate(prevDate))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +72,73 @@ export function Calendar(): ReactNode {
|
|||||||
setCurrentMonth(new Date(year, month + 1, 1))
|
setCurrentMonth(new Date(year, month + 1, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDayClick = (date: string) => {
|
||||||
|
setSelectedDate(date)
|
||||||
|
setModalError(null)
|
||||||
|
setIsModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
|
||||||
|
return (plannedDays ?? []).find((pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSavePlannedDay = async (payload: {
|
||||||
|
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||||
|
parentId: string
|
||||||
|
isDubbeldag: boolean
|
||||||
|
}) => {
|
||||||
|
if (!selectedDate || !selectedChildId || !workspace?.id || !personalDataUuid) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
setModalError(null)
|
||||||
|
|
||||||
|
const existingDay = getExistingDay(selectedDate)
|
||||||
|
|
||||||
|
if (existingDay) {
|
||||||
|
await update(existingDay.id, {
|
||||||
|
day_type: payload.dayType,
|
||||||
|
is_dubbeldag: payload.isDubbeldag,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await add({
|
||||||
|
workspace_id: workspace.id,
|
||||||
|
child_id: selectedChildId,
|
||||||
|
parent_personal_data_uuid: personalDataUuid,
|
||||||
|
date: selectedDate,
|
||||||
|
day_type: payload.dayType,
|
||||||
|
is_dubbeldag: payload.isDubbeldag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setModalError(err instanceof Error ? err.message : 'Failed to save planned day')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeletePlannedDay = async () => {
|
||||||
|
if (!selectedDate) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
setModalError(null)
|
||||||
|
|
||||||
|
const existingDay = getExistingDay(selectedDate)
|
||||||
|
if (existingDay) {
|
||||||
|
await remove(existingDay.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setModalError(err instanceof Error ? err.message : 'Failed to delete planned day')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div data-testid="calendar" className="calendar">
|
<div data-testid="calendar" className="calendar">
|
||||||
@@ -82,7 +155,7 @@ export function Calendar(): ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="calendar__grid">
|
<div className="calendar__grid">
|
||||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
|
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map((day) => (
|
||||||
<div key={day} className="calendar__weekday">
|
<div key={day} className="calendar__weekday">
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,12 +165,15 @@ export function Calendar(): ReactNode {
|
|||||||
week.map((date, dayIdx) => {
|
week.map((date, dayIdx) => {
|
||||||
const events = getEventsForDate(date)
|
const events = getEventsForDate(date)
|
||||||
const isCurrentMonth = new Date(date).getMonth() === month
|
const isCurrentMonth = new Date(date).getMonth() === month
|
||||||
|
const isToday = date === getTodayString()
|
||||||
return (
|
return (
|
||||||
<CalendarDay
|
<CalendarDay
|
||||||
key={`${weekIdx}-${dayIdx}`}
|
key={`${weekIdx}-${dayIdx}`}
|
||||||
date={date}
|
date={date}
|
||||||
events={events}
|
events={events}
|
||||||
isCurrentMonth={isCurrentMonth}
|
isCurrentMonth={isCurrentMonth}
|
||||||
|
isToday={isToday}
|
||||||
|
onClick={isCurrentMonth ? () => handleDayClick(date) : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -118,6 +194,21 @@ export function Calendar(): ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedDate && (
|
||||||
|
<PlannedDayModal
|
||||||
|
date={selectedDate}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
existingDay={getExistingDay(selectedDate) ?? null}
|
||||||
|
parents={[{ id: personalDataUuid ?? '', name: 'Current Parent' }]}
|
||||||
|
selectedParentId={personalDataUuid ?? ''}
|
||||||
|
onSave={handleSavePlannedDay}
|
||||||
|
onDelete={getExistingDay(selectedDate) ? handleDeletePlannedDay : undefined}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
isSaving={isSaving}
|
||||||
|
error={modalError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
@@ -130,6 +221,10 @@ function formatDate(date: Date): string {
|
|||||||
return `${year}-${month}-${day}`
|
return `${year}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTodayString(): string {
|
||||||
|
return formatDate(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
function getColorForType(type: string): string {
|
function getColorForType(type: string): string {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
vacation: '#4CAF50',
|
vacation: '#4CAF50',
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
.calendar {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__header button {
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__header button:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__weekday {
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 84px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover {
|
||||||
|
border-color: #0066cc;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.1);
|
||||||
|
background-color: #f9fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:focus-visible {
|
||||||
|
outline: 2px solid #0066cc;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--disabled {
|
||||||
|
background-color: #fafafa;
|
||||||
|
color: #999;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--disabled:hover {
|
||||||
|
border-color: #e0e0e0;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--has-events {
|
||||||
|
background-color: #fafbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day__number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--disabled .calendar-day__number {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--today .calendar-day__number {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: #fff;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day__events {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day__event {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__legend {
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__legend h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__legend-items {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar__legend-item .calendar-event {
|
||||||
|
width: 12px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
min-width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user