@mounaji_npm/booking-calendar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +35 -0
- package/src/index.jsx +159 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mounaji_npm/booking-calendar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight month-grid calendar component for rental bookings management",
|
|
5
|
+
"keywords": ["calendar", "booking", "rentals", "react", "saas", "mounaji"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"registry": "https://registry.npmjs.org/"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/mounajibookingcalendar.umd.cjs",
|
|
13
|
+
"module": "./dist/mounajibookingcalendar.es.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": "./dist/mounajibookingcalendar.es.js",
|
|
17
|
+
"require": "./dist/mounajibookingcalendar.umd.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": ["dist", "src", "DOCS.md"],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "vite build",
|
|
23
|
+
"dev": "vite build --watch"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=17.0.0",
|
|
27
|
+
"react-dom": ">=17.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
31
|
+
"react": "^19.0.0",
|
|
32
|
+
"react-dom": "^19.0.0",
|
|
33
|
+
"vite": "^6.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.jsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import React, { useMemo, useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BookingCalendar
|
|
5
|
+
* A lightweight month-grid calendar for rental bookings.
|
|
6
|
+
*
|
|
7
|
+
* Props:
|
|
8
|
+
* - bookings: Array<{ id, unit_id?, check_in, check_out, status, guest_name? }>
|
|
9
|
+
* - units?: Array<{ id, name }> (optional; when provided shows a unit column header)
|
|
10
|
+
* - month?: Date (defaults to today)
|
|
11
|
+
* - onMonthChange? (next: Date) => void
|
|
12
|
+
* - onSelectDay? (date: Date) => void
|
|
13
|
+
* - onSelectBooking?(booking) => void
|
|
14
|
+
* - statuses?: color map for status -> css color
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const WEEK = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
|
|
18
|
+
|
|
19
|
+
const STATUS_COLORS = {
|
|
20
|
+
confirmed: '#16a34a',
|
|
21
|
+
pending: '#f59e0b',
|
|
22
|
+
blocked: '#6b7280',
|
|
23
|
+
cancelled: '#ef4444'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const fmt = (d) => {
|
|
27
|
+
const y = d.getFullYear();
|
|
28
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
29
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
30
|
+
return `${y}-${m}-${day}`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const parse = (s) => (s ? new Date(s + 'T00:00:00') : null);
|
|
34
|
+
|
|
35
|
+
function dayKey(d) { return fmt(d); }
|
|
36
|
+
|
|
37
|
+
/** Returns true if booking range overlaps a given date. */
|
|
38
|
+
function bookingOnDate(booking, date) {
|
|
39
|
+
const start = parse(typeof booking.check_in === 'string' ? booking.check_in.slice(0, 10) : booking.check_in);
|
|
40
|
+
const end = parse(typeof booking.check_out === 'string' ? booking.check_out.slice(0, 10) : booking.check_out);
|
|
41
|
+
if (!start || !end) return false;
|
|
42
|
+
const cur = fmt(date);
|
|
43
|
+
return cur >= fmt(start) && cur <= fmt(end);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function BookingCalendar({
|
|
47
|
+
bookings = [],
|
|
48
|
+
units,
|
|
49
|
+
month: monthProp,
|
|
50
|
+
onMonthChange,
|
|
51
|
+
onSelectDay,
|
|
52
|
+
onSelectBooking,
|
|
53
|
+
statuses = STATUS_COLORS
|
|
54
|
+
}) {
|
|
55
|
+
const [month, setMonth] = useState(monthProp ?? new Date());
|
|
56
|
+
|
|
57
|
+
const cells = useMemo(() => {
|
|
58
|
+
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
59
|
+
const startDay = first.getDay();
|
|
60
|
+
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
|
|
61
|
+
const arr = [];
|
|
62
|
+
for (let i = 0; i < startDay; i++) arr.push(null);
|
|
63
|
+
for (let d = 1; d <= daysInMonth; d++) arr.push(new Date(month.getFullYear(), month.getMonth(), d));
|
|
64
|
+
while (arr.length % 7 !== 0) arr.push(null);
|
|
65
|
+
return arr;
|
|
66
|
+
}, [month]);
|
|
67
|
+
|
|
68
|
+
const bookingsByDay = useMemo(() => {
|
|
69
|
+
const map = new Map();
|
|
70
|
+
for (const date of cells.filter(Boolean)) {
|
|
71
|
+
const k = dayKey(date);
|
|
72
|
+
map.set(k, bookings.filter(b => bookingOnDate(b, date)));
|
|
73
|
+
}
|
|
74
|
+
return map;
|
|
75
|
+
}, [cells, bookings]);
|
|
76
|
+
|
|
77
|
+
const prevMonth = useCallback(() => {
|
|
78
|
+
const m = new Date(month);
|
|
79
|
+
m.setMonth(m.getMonth() - 1);
|
|
80
|
+
setMonth(m);
|
|
81
|
+
onMonthChange?.(m);
|
|
82
|
+
}, [month, onMonthChange]);
|
|
83
|
+
|
|
84
|
+
const nextMonth = useCallback(() => {
|
|
85
|
+
const m = new Date(month);
|
|
86
|
+
m.setMonth(m.getMonth() + 1);
|
|
87
|
+
setMonth(m);
|
|
88
|
+
onMonthChange?.(m);
|
|
89
|
+
}, [month, onMonthChange]);
|
|
90
|
+
|
|
91
|
+
const label = useMemo(
|
|
92
|
+
() => month.toLocaleDateString('es-AR', { month: 'long', year: 'numeric' }),
|
|
93
|
+
[month]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="mn-booking-calendar" style={{ fontFamily: 'system-ui, sans-serif' }}>
|
|
98
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
99
|
+
<button onClick={prevMonth} aria-label="Mes anterior">‹</button>
|
|
100
|
+
<strong style={{ textTransform: 'capitalize' }}>{label}</strong>
|
|
101
|
+
<button onClick={nextMonth} aria-label="Mes siguiente">›</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
|
|
105
|
+
{WEEK.map(w => (
|
|
106
|
+
<div key={w} style={{ textAlign: 'center', fontWeight: 600, padding: '4px 0', fontSize: 12, color: '#6b7280' }}>{w}</div>
|
|
107
|
+
))}
|
|
108
|
+
|
|
109
|
+
{cells.map((date, i) => {
|
|
110
|
+
if (!date) return <div key={i} style={{ minHeight: 72, background: '#f9fafb', borderRadius: 6 }} />;
|
|
111
|
+
const k = dayKey(date);
|
|
112
|
+
const dayBookings = bookingsByDay.get(k) ?? [];
|
|
113
|
+
const isToday = fmt(date) === fmt(new Date());
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
onClick={() => onSelectDay?.(date)}
|
|
118
|
+
style={{
|
|
119
|
+
minHeight: 72,
|
|
120
|
+
border: '1px solid #e5e7eb',
|
|
121
|
+
borderRadius: 6,
|
|
122
|
+
padding: 4,
|
|
123
|
+
cursor: 'pointer',
|
|
124
|
+
background: isToday ? '#eff6ff' : '#fff'
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 2 }}>{date.getDate()}</div>
|
|
128
|
+
{dayBookings.slice(0, 2).map(b => (
|
|
129
|
+
<div
|
|
130
|
+
key={b.id}
|
|
131
|
+
onClick={(e) => { e.stopPropagation(); onSelectBooking?.(b); }}
|
|
132
|
+
title={b.guest_name || b.status}
|
|
133
|
+
style={{
|
|
134
|
+
fontSize: 10,
|
|
135
|
+
color: '#fff',
|
|
136
|
+
background: statuses[b.status] || '#3b82f6',
|
|
137
|
+
borderRadius: 4,
|
|
138
|
+
padding: '1px 4px',
|
|
139
|
+
marginBottom: 2,
|
|
140
|
+
overflow: 'hidden',
|
|
141
|
+
textOverflow: 'ellipsis',
|
|
142
|
+
whiteSpace: 'nowrap'
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{b.guest_name || b.status}
|
|
146
|
+
</div>
|
|
147
|
+
))}
|
|
148
|
+
{dayBookings.length > 2 && (
|
|
149
|
+
<div style={{ fontSize: 10, color: '#6b7280' }}>+{dayBookings.length - 2} más</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default BookingCalendar;
|