up
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
let properties = [];
|
||||
let events = [];
|
||||
let loading = true;
|
||||
let filterProperty = '';
|
||||
let showForm = false;
|
||||
let error = '';
|
||||
|
||||
let today = new Date();
|
||||
let viewYear = today.getFullYear();
|
||||
let viewMonth = today.getMonth(); // 0-indexed
|
||||
|
||||
const empty = () => ({
|
||||
property_id: '', title: '',
|
||||
start_date: today.toISOString().slice(0,10),
|
||||
end_date: today.toISOString().slice(0,10),
|
||||
notes: ''
|
||||
});
|
||||
let form = empty();
|
||||
|
||||
onMount(async () => {
|
||||
properties = await api.properties.list() || [];
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const from = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const to = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${lastDay}`;
|
||||
const params = { from, to };
|
||||
if (filterProperty) params.property_id = filterProperty;
|
||||
events = await api.calendar.list(params) || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) { viewMonth = 11; viewYear--; } else { viewMonth--; }
|
||||
load();
|
||||
}
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) { viewMonth = 0; viewYear++; } else { viewMonth++; }
|
||||
load();
|
||||
}
|
||||
|
||||
// Génère les jours du mois pour la grille
|
||||
$: calendarDays = (() => {
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const days = [];
|
||||
// Padding début (lundi = 0)
|
||||
const offset = (firstDay === 0 ? 6 : firstDay - 1);
|
||||
for (let i = 0; i < offset; i++) days.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||
return days;
|
||||
})();
|
||||
|
||||
function isToday(day) {
|
||||
return day === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
|
||||
}
|
||||
|
||||
// Calcule pour chaque jour du mois : l'événement correspondant (ou null)
|
||||
// iCal (airbnb) : DTEND exclusif → ds < end_date
|
||||
// Manuel : date départ incluse → ds <= end_date
|
||||
$: eventByDay = (() => {
|
||||
const map = {};
|
||||
const mStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const ds = `${mStr}-${String(d).padStart(2, '0')}`;
|
||||
map[d] = events.find(e =>
|
||||
ds >= e.start_date && (e.source === 'airbnb' ? ds < e.end_date : ds <= e.end_date)
|
||||
) || null;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
$: occupiedDays = Object.values(eventByDay).filter(Boolean).length;
|
||||
$: daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
$: occupancyRate = daysInMonth > 0 ? Math.round((occupiedDays / daysInMonth) * 100) : 0;
|
||||
// Séjours dont l'arrivée est dans le mois affiché (pas les séjours démarrés avant)
|
||||
$: sejoursThisMonth = (() => {
|
||||
const monthStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
|
||||
return events.filter(e => e.start_date.startsWith(monthStr)).length;
|
||||
})();
|
||||
|
||||
const monthNames = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const dayNames = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
|
||||
|
||||
const sourceBg = {
|
||||
airbnb: 'bg-orange-100 dark:bg-orange-900',
|
||||
manual: 'bg-blue-100 dark:bg-blue-900'
|
||||
};
|
||||
|
||||
let syncing = false;
|
||||
let syncMsg = '';
|
||||
let syncError = '';
|
||||
|
||||
async function syncCalendars() {
|
||||
syncing = true;
|
||||
syncMsg = '';
|
||||
syncError = '';
|
||||
try {
|
||||
const res = await fetch('/api/calendar/sync', { method: 'POST', credentials: 'include' });
|
||||
const results = await res.json();
|
||||
const total = results.reduce((s, r) => s + (r.imported || 0), 0);
|
||||
const errs = results.filter(r => r.error);
|
||||
if (errs.length > 0) {
|
||||
syncError = errs.map(r => `${r.property}: ${r.error}`).join(' | ');
|
||||
} else {
|
||||
syncMsg = `${total} événement(s) importé(s)`;
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
syncError = 'Erreur de synchronisation: ' + e.message;
|
||||
}
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
error = '';
|
||||
if (!form.property_id || !form.start_date || !form.end_date) { error = 'Bien et dates requis.'; return; }
|
||||
try {
|
||||
await api.calendar.createEvent(form);
|
||||
showForm = false;
|
||||
await load();
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<CalendarDays size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Calendrier</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button on:click={syncCalendars} disabled={syncing}
|
||||
class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50">
|
||||
<RefreshCw size={15} class={syncing ? 'animate-spin' : ''}/> Synchroniser
|
||||
</button>
|
||||
<button on:click={() => { showForm = true; form = empty(); error = ''; }}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16}/> Ajouter occupation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if syncMsg}
|
||||
<p class="text-sm text-green-600 dark:text-green-400 mb-3">{syncMsg}</p>
|
||||
{/if}
|
||||
{#if syncError}
|
||||
<p class="text-sm text-red-500 dark:text-red-400 mb-3">{syncError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Filtres + navigation -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
<select bind:value={filterProperty} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous les biens</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<button on:click={prevMonth} class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 transition-colors"><ChevronLeft size={18}/></button>
|
||||
<span class="text-base font-semibold text-gray-900 dark:text-white w-36 text-center">{monthNames[viewMonth]} {viewYear}</span>
|
||||
<button on:click={nextMonth} class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 transition-colors"><ChevronRight size={18}/></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-5">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Jours occupés</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{occupiedDays} <span class="text-sm font-normal text-gray-400">/ {daysInMonth}</span></p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Taux d'occupation</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{occupancyRate}%</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Séjours ce mois</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{sejoursThisMonth}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendrier grille -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<!-- Jours de la semaine -->
|
||||
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-800">
|
||||
{#each dayNames as d}
|
||||
<div class="py-3 text-center text-xs font-medium text-gray-400 dark:text-gray-500">{d}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Jours -->
|
||||
<div class="grid grid-cols-7">
|
||||
{#each calendarDays as day, i}
|
||||
{@const event = eventByDay[day] ?? null}
|
||||
{@const occupied = !!event}
|
||||
<div class="border-b border-r border-gray-50 dark:border-gray-800/50 min-h-[72px] p-2 relative
|
||||
{!day ? 'bg-gray-50/50 dark:bg-gray-800/20' : ''}
|
||||
{occupied ? (event.source === 'airbnb' ? 'bg-orange-50 dark:bg-orange-950/30' : 'bg-blue-50 dark:bg-blue-950/30') : ''}">
|
||||
{#if day}
|
||||
<span class="text-xs font-medium {isToday(day) ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full' : 'text-gray-700 dark:text-gray-300'}">
|
||||
{day}
|
||||
</span>
|
||||
{#if event && event.start_date === `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`}
|
||||
<div class="mt-1 text-xs px-1.5 py-0.5 rounded font-medium truncate
|
||||
{event.source === 'airbnb' ? 'bg-orange-200 text-orange-800 dark:bg-orange-900 dark:text-orange-200' : 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}">
|
||||
{event.title || (event.source === 'airbnb' ? 'Airbnb' : 'Locataire')}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="flex gap-4 mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-orange-200 dark:bg-orange-900"></span>Airbnb (sync auto)</span>
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-blue-200 dark:bg-blue-900"></span>Manuel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter une période d'occupation</h2>
|
||||
<button on:click={() => showForm = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
|
||||
<select bind:value={form.property_id}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Sélectionner...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Intitulé</label>
|
||||
<input bind:value={form.title} placeholder="Ex: Famille Dupont"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Arrivée *</label>
|
||||
<input type="date" bind:value={form.start_date}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Départ *</label>
|
||||
<input type="date" bind:value={form.end_date}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={() => showForm = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={saveEvent}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user