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 { CalendarDay } from '@/components/common/CalendarDay'
|
||||
import { CalendarEvent } from '@/components/common/CalendarEvent'
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import './calendar.css'
|
||||
|
||||
export function Calendar(): ReactNode {
|
||||
const { isLoading: wsLoading, selectedChildId } = useWorkspace()
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date(2026, 5))
|
||||
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid } = useWorkspace()
|
||||
const { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined)
|
||||
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
|
||||
|
||||
@@ -24,13 +30,13 @@ export function Calendar(): ReactNode {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const daysInMonth = lastDay.getDate()
|
||||
const startingDayOfWeek = firstDay.getDay()
|
||||
const startOffset = (firstDay.getDay() + 6) % 7
|
||||
|
||||
const weeks: string[][] = []
|
||||
let currentWeek: string[] = []
|
||||
|
||||
for (let i = 0; i < startingDayOfWeek; i++) {
|
||||
const prevDate = new Date(year, month, -startingDayOfWeek + i + 1)
|
||||
for (let i = 0; i < startOffset; i++) {
|
||||
const prevDate = new Date(year, month, -startOffset + i + 1)
|
||||
currentWeek.push(formatDate(prevDate))
|
||||
}
|
||||
|
||||
@@ -66,6 +72,73 @@ export function Calendar(): ReactNode {
|
||||
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 (
|
||||
<Layout>
|
||||
<div data-testid="calendar" className="calendar">
|
||||
@@ -82,7 +155,7 @@ export function Calendar(): ReactNode {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{day}
|
||||
</div>
|
||||
@@ -92,12 +165,15 @@ export function Calendar(): ReactNode {
|
||||
week.map((date, dayIdx) => {
|
||||
const events = getEventsForDate(date)
|
||||
const isCurrentMonth = new Date(date).getMonth() === month
|
||||
const isToday = date === getTodayString()
|
||||
return (
|
||||
<CalendarDay
|
||||
key={`${weekIdx}-${dayIdx}`}
|
||||
date={date}
|
||||
events={events}
|
||||
isCurrentMonth={isCurrentMonth}
|
||||
isToday={isToday}
|
||||
onClick={isCurrentMonth ? () => handleDayClick(date) : undefined}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -118,6 +194,21 @@ export function Calendar(): ReactNode {
|
||||
</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>
|
||||
</Layout>
|
||||
)
|
||||
@@ -130,6 +221,10 @@ function formatDate(date: Date): string {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getTodayString(): string {
|
||||
return formatDate(new Date())
|
||||
}
|
||||
|
||||
function getColorForType(type: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
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