This commit is contained in:
2026-04-19 12:59:18 +02:00
parent c01876ad81
commit 7065cb3945
8 changed files with 347 additions and 25 deletions
+1
View File
@@ -29,6 +29,7 @@ frontend/.env.*.local
# ── Web embed (build artifact) ────────────────────────────────────────────────
web/dist/
web/build/
# ── Environnement ─────────────────────────────────────────────────────────────
.env
+1
View File
@@ -165,6 +165,7 @@ func main() {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}).Methods("POST")
protected.HandleFunc("/calendar/import-booking", calendarHandler.ImportBookingCSV).Methods("POST")
protected.HandleFunc("/calendar/{id}", calendarHandler.UpdateEvent).Methods("PUT")
protected.HandleFunc("/calendar/{id}", calendarHandler.DeleteEvent).Methods("DELETE")
+1 -1
View File
@@ -207,7 +207,7 @@
let occupied = 0;
for (let d = 1; d <= days; d++) {
const ds = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
if (calEvents.some(e => ds >= e.start_date && ds < e.end_date)) occupied++;
if (calEvents.some(e => ds >= e.start_date && (e.source === 'airbnb' ? ds < e.end_date : ds <= e.end_date))) occupied++;
}
return days > 0 ? Math.round((occupied/days)*100) : 0;
})();
+91 -22
View File
@@ -1,11 +1,10 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw } from 'lucide-svelte';
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw, Upload } from 'lucide-svelte';
let properties = [];
let events = [];
let loading = true;
let filterProperty = '';
let showForm = false;
let error = '';
@@ -28,14 +27,12 @@
});
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() {
@@ -91,11 +88,6 @@
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 = '';
@@ -130,6 +122,34 @@
await load();
} catch (e) { error = e.message; }
}
// ── Import Booking.com CSV ────────────────────────────────────────────────
let showBookingImport = false;
let bookingPropertyId = '';
let bookingFile = null;
let bookingImporting = false;
let bookingMsg = '';
let bookingError = '';
async function importBookingCSV() {
bookingError = '';
bookingMsg = '';
if (!bookingPropertyId) { bookingError = 'Sélectionner un bien.'; return; }
if (!bookingFile) { bookingError = 'Sélectionner un fichier CSV.'; return; }
bookingImporting = true;
try {
const fd = new FormData();
fd.append('property_id', bookingPropertyId);
fd.append('file', bookingFile);
const res = await fetch('/api/calendar/import-booking', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
const r = await res.json();
bookingMsg = `${r.imported} réservation(s) importée(s) sur ${r.total}`;
showBookingImport = false;
await load();
} catch (e) { bookingError = e.message; }
bookingImporting = false;
}
</script>
<div class="p-6 max-w-5xl mx-auto">
@@ -144,6 +164,10 @@
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={() => { showBookingImport = true; bookingMsg = ''; bookingError = ''; bookingFile = null; }}
class="flex items-center gap-2 px-4 py-2 border border-teal-200 dark:border-teal-700 rounded-lg text-sm text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 transition-colors">
<Upload size={15}/> Booking.com
</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
@@ -157,6 +181,9 @@
{#if syncError}
<p class="text-sm text-red-500 dark:text-red-400 mb-3">{syncError}</p>
{/if}
{#if bookingMsg}
<p class="text-sm text-teal-600 dark:text-teal-400 mb-3">{bookingMsg}</p>
{/if}
<!-- Filtres + navigation -->
<div class="flex flex-wrap items-center gap-3 mb-5">
@@ -198,20 +225,20 @@
</div>
<!-- Jours -->
<div class="grid grid-cols-7">
{#each calendarDays as day, i}
{#each calendarDays as day}
{@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') : ''}">
{occupied ? (event.source === 'airbnb' ? 'bg-orange-50 dark:bg-orange-950/30' : event.source === 'booking' ? 'bg-teal-50 dark:bg-teal-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')}
{event.source === 'airbnb' ? 'bg-orange-200 text-orange-800 dark:bg-orange-900 dark:text-orange-200' : event.source === 'booking' ? 'bg-teal-200 text-teal-800 dark:bg-teal-900 dark:text-teal-200' : 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}">
{event.title || (event.source === 'airbnb' ? 'Airbnb' : event.source === 'booking' ? 'Booking.com' : 'Locataire')}
</div>
{/if}
{/if}
@@ -223,13 +250,15 @@
<!-- 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-teal-200 dark:bg-teal-900"></span>Booking.com (import CSV)</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}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div role="dialog" aria-modal="true" 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>
@@ -238,27 +267,27 @@
<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}
<label for="form-property" class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
<select id="form-property" 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"
<label for="form-title" class="block text-xs font-medium text-gray-500 mb-1">Intitulé</label>
<input id="form-title" 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}
<label for="form-start" class="block text-xs font-medium text-gray-500 mb-1">Arrivée *</label>
<input id="form-start" 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}
<label for="form-end" class="block text-xs font-medium text-gray-500 mb-1">Départ *</label>
<input id="form-end" 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>
@@ -273,3 +302,43 @@
</div>
</div>
{/if}
<!-- Modal import Booking.com -->
{#if showBookingImport}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div role="dialog" aria-modal="true" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showBookingImport = 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">Importer réservations Booking.com</h2>
<button on:click={() => showBookingImport = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4">
{#if bookingError}<p class="text-red-500 text-sm">{bookingError}</p>{/if}
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
<select bind:value={bookingPropertyId}
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-teal-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">Fichier CSV Booking.com *</label>
<input type="file" accept=".xls,.xlsx,.csv"
on:change={(e) => bookingFile = e.target.files[0]}
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-medium file:bg-teal-50 file:text-teal-700 hover:file:bg-teal-100 dark:file:bg-teal-900/30 dark:file:text-teal-300"/>
<p class="text-xs text-gray-400 mt-1">Extranet Booking.com → Réservations → Exporter → CSV</p>
</div>
<p class="text-xs text-gray-400">Les réservations annulées sont ignorées. L'import remplace les données Booking.com précédentes pour ce bien.</p>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={() => showBookingImport = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={importBookingCSV} disabled={bookingImporting}
class="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{#if bookingImporting}<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>{:else}<Upload size={15}/>{/if}
Importer
</button>
</div>
</div>
</div>
{/if}
+11 -1
View File
@@ -6,16 +6,26 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.24.0
golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.47.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect
github.com/extrame/xls v0.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+22
View File
@@ -1,5 +1,9 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 h1:n+nk0bNe2+gVbRI8WRbLFVwwcBQ0rr5p+gzkKb6ol8c=
github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7/go.mod h1:GPpMrAfHdb8IdQ1/R2uIRBsNfnPnwsYE9YYI5WyY1zw=
github.com/extrame/xls v0.0.1 h1:jI7L/o3z73TyyENPopsLS/Jlekm3nF1a/kF5hKBvy/k=
github.com/extrame/xls v0.0.1/go.mod h1:iACcgahst7BboCpIMSpnFs4SKyU9ZjsvZBfNbUxZOJI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -13,11 +17,29 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+176 -1
View File
@@ -1,13 +1,21 @@
package calendar
import (
"bytes"
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
xls "github.com/extrame/xls"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/xuri/excelize/v2"
)
// ── Models ────────────────────────────────────────────────────────────────────
@@ -121,6 +129,21 @@ func (s *Store) Delete(id string) error {
return err
}
func (s *Store) DeleteBookingEvents(propertyID string) error {
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='booking'`, propertyID)
return err
}
func (s *Store) InsertFromBooking(e *Event) error {
e.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT OR REPLACE INTO calendar_events (id, property_id, title, start_date, end_date, source, ical_uid, notes)
VALUES (?,?,?,?,?,?,?,?)`,
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, "booking", e.IcalUID, e.Notes,
)
return err
}
func (s *Store) LogSync(propertyID, status string, imported int, errMsg string) {
s.db.Exec(
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
@@ -190,10 +213,162 @@ func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
// Le service iCal expose un endpoint pour forcer la sync
respond(w, map[string]string{"status": "sync triggered"})
}
func (h *Handler) ImportBookingCSV(w http.ResponseWriter, r *http.Request) {
propertyID := r.FormValue("property_id")
if propertyID == "" {
http.Error(w, "property_id requis", http.StatusBadRequest)
return
}
f, fh, err := r.FormFile("file")
if err != nil {
http.Error(w, "fichier requis", http.StatusBadRequest)
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
http.Error(w, "erreur lecture fichier", http.StatusInternalServerError)
return
}
var events []Event
ext := strings.ToLower(filepath.Ext(fh.Filename))
switch ext {
case ".xlsx":
events, err = parseBookingXLSX(data, propertyID)
case ".xls":
events, err = parseBookingXLS(data, propertyID)
default: // .csv
events, err = parseBookingCSV(bytes.NewReader(data), propertyID)
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.store.DeleteBookingEvents(propertyID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
imported := 0
for _, e := range events {
if err := h.store.InsertFromBooking(&e); err != nil {
continue
}
imported++
}
respond(w, map[string]int{"imported": imported, "total": len(events)})
}
// extractBookingRows convertit des lignes (header + data) en Event
func extractBookingRows(rows [][]string, propertyID string) []Event {
if len(rows) == 0 {
return nil
}
col := make(map[string]int)
for i, h := range rows[0] {
col[strings.TrimSpace(h)] = i
}
get := func(row []string, name string) string {
i, ok := col[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
var events []Event
for _, row := range rows[1:] {
status := get(row, "Status")
if status == "cancelled" || status == "invalid" || status == "no_show" || status == "" {
continue
}
bookNum := get(row, "Book number")
checkin := normalizeBookingDate(get(row, "Check-in"))
checkout := normalizeBookingDate(get(row, "Check-out"))
if bookNum == "" || checkin == "" || checkout == "" {
continue
}
notes := get(row, "Price")
if r := get(row, "Remarks"); r != "" {
if notes != "" {
notes += " — " + r
} else {
notes = r
}
}
events = append(events, Event{
PropertyID: propertyID,
IcalUID: "booking-" + bookNum,
Title: get(row, "Guest name(s)"),
StartDate: checkin,
EndDate: checkout,
Source: "booking",
Notes: notes,
})
}
return events
}
// normalizeBookingDate accepte YYYY-MM-DD, DD/MM/YYYY et MM/DD/YYYY
func normalizeBookingDate(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 10 && s[4] == '-' {
return s[:10] // déjà YYYY-MM-DD
}
if len(s) == 10 && s[2] == '/' {
// DD/MM/YYYY (format européen Booking)
return s[6:10] + "-" + s[3:5] + "-" + s[0:2]
}
return s
}
func parseBookingCSV(r io.Reader, propertyID string) ([]Event, error) {
reader := csv.NewReader(r)
rows, err := reader.ReadAll()
if err != nil {
return nil, err
}
return extractBookingRows(rows, propertyID), nil
}
func parseBookingXLSX(data []byte, propertyID string) ([]Event, error) {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("erreur lecture XLSX: %w", err)
}
defer f.Close()
rows, err := f.GetRows(f.GetSheetName(0))
if err != nil {
return nil, err
}
return extractBookingRows(rows, propertyID), nil
}
func parseBookingXLS(data []byte, propertyID string) ([]Event, error) {
wb, err := xls.OpenReader(bytes.NewReader(data), "utf-8")
if err != nil {
return nil, fmt.Errorf("erreur lecture XLS: %w", err)
}
sheet := wb.GetSheet(0)
if sheet == nil {
return nil, fmt.Errorf("feuille Excel vide")
}
var rows [][]string
for i := 0; i <= int(sheet.MaxRow); i++ {
row := sheet.Row(i)
var cells []string
for j := 0; j < row.LastCol(); j++ {
cells = append(cells, row.Col(j))
}
rows = append(rows, cells)
}
return extractBookingRows(rows, propertyID), nil
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
+44
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"log"
"strings"
_ "modernc.org/sqlite"
)
@@ -36,10 +37,53 @@ func Migrate(db *sql.DB) error {
return err
}
}
if err := migrateAddBookingSource(db); err != nil {
log.Printf("⚠ migration booking source: %v", err)
}
log.Println("✓ Migrations appliquées")
return nil
}
// migrateAddBookingSource ajoute 'booking' aux sources autorisées de calendar_events.
// Vérifie d'abord le DDL pour ne s'exécuter qu'une seule fois.
func migrateAddBookingSource(db *sql.DB) error {
var ddl string
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE name='calendar_events' AND type='table'`).Scan(&ddl); err != nil {
return nil // table absente, rien à faire
}
if strings.Contains(ddl, "'booking'") {
return nil // déjà migré
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, step := range []string{
`CREATE TABLE calendar_events_new (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
title TEXT,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
source TEXT NOT NULL CHECK(source IN ('airbnb','manual','booking')),
ical_uid TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(property_id, ical_uid)
)`,
`INSERT INTO calendar_events_new SELECT * FROM calendar_events`,
`DROP TABLE calendar_events`,
`ALTER TABLE calendar_events_new RENAME TO calendar_events`,
} {
if _, err := tx.Exec(step); err != nil {
return err
}
}
log.Println("✓ Migration calendar_events: source 'booking' ajouté")
return tx.Commit()
}
const sqlCreateUsers = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,