Reworked calendar form
Build and Publish / Build and Publish Docker Image (push) Successful in 2m17s
Build and Publish / Build and Publish Docker Image (push) Successful in 2m17s
This commit is contained in:
Generated
+101
-1
@@ -29,6 +29,15 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1574,6 +1583,15 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -1649,6 +1667,15 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2093,6 +2120,23 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -2376,6 +2420,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -2724,6 +2777,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.47"
|
version = "0.3.47"
|
||||||
@@ -3012,6 +3074,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3113,6 +3205,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -3255,7 +3353,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
@@ -3268,6 +3366,8 @@ dependencies = [
|
|||||||
"serde_html_form",
|
"serde_html_form",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "0.1.9"
|
version = "0.1.10"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -17,3 +17,4 @@ futures = "0.3"
|
|||||||
tokio = { version = "1", features = ["fs"] }
|
tokio = { version = "1", features = ["fs"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ struct ScheduleNewTemplate<'a> {
|
|||||||
clients: Vec<Client>,
|
clients: Vec<Client>,
|
||||||
users: Vec<User>,
|
users: Vec<User>,
|
||||||
current_user_id: i64,
|
current_user_id: i64,
|
||||||
|
timezone: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
#[derive(Debug, Template)]
|
||||||
@@ -1110,6 +1111,7 @@ async fn schedule_new_page(
|
|||||||
let current_user_id = get_admin_id(&session).await.unwrap_or(0);
|
let current_user_id = get_admin_id(&session).await.unwrap_or(0);
|
||||||
let clients = query!(Client, $status == "active").all(&db).await?;
|
let clients = query!(Client, $status == "active").all(&db).await?;
|
||||||
let users = query!(User, $status == "active").all(&db).await?;
|
let users = query!(User, $status == "active").all(&db).await?;
|
||||||
|
let tz = crate::tz::load_tz(&db).await;
|
||||||
let body = ScheduleNewTemplate {
|
let body = ScheduleNewTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
@@ -1117,6 +1119,7 @@ async fn schedule_new_page(
|
|||||||
clients,
|
clients,
|
||||||
users,
|
users,
|
||||||
current_user_id,
|
current_user_id,
|
||||||
|
timezone: tz.to_string(),
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
|
|||||||
+2
-2
@@ -400,7 +400,7 @@ static RU: Translations = Translations {
|
|||||||
schedule_new_title: "Запланировать визиты",
|
schedule_new_title: "Запланировать визиты",
|
||||||
schedule_client: "Клиент",
|
schedule_client: "Клиент",
|
||||||
schedule_admin: "Исполнитель",
|
schedule_admin: "Исполнитель",
|
||||||
schedule_default_time: "Время по умолчанию",
|
schedule_default_time: "Время",
|
||||||
schedule_time_start: "С",
|
schedule_time_start: "С",
|
||||||
schedule_time_end: "До",
|
schedule_time_end: "До",
|
||||||
schedule_pick_dates: "Добавить дату",
|
schedule_pick_dates: "Добавить дату",
|
||||||
@@ -603,7 +603,7 @@ static EN: Translations = Translations {
|
|||||||
schedule_new_title: "Plan Visits",
|
schedule_new_title: "Plan Visits",
|
||||||
schedule_client: "Client",
|
schedule_client: "Client",
|
||||||
schedule_admin: "Assigned to",
|
schedule_admin: "Assigned to",
|
||||||
schedule_default_time: "Default Time",
|
schedule_default_time: "Time",
|
||||||
schedule_time_start: "From",
|
schedule_time_start: "From",
|
||||||
schedule_time_end: "To",
|
schedule_time_end: "To",
|
||||||
schedule_pick_dates: "Add date",
|
schedule_pick_dates: "Add date",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ mod public;
|
|||||||
mod telegram;
|
mod telegram;
|
||||||
mod tz;
|
mod tz;
|
||||||
|
|
||||||
|
use tracing_subscriber;
|
||||||
|
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::config::{
|
use cot::config::{
|
||||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
|
||||||
@@ -97,5 +99,10 @@ impl Project for PettingProject {
|
|||||||
|
|
||||||
#[cot::main]
|
#[cot::main]
|
||||||
fn main() -> impl Project {
|
fn main() -> impl Project {
|
||||||
|
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.try_init();
|
||||||
PettingProject
|
PettingProject
|
||||||
}
|
}
|
||||||
|
|||||||
+347
-164
@@ -9,238 +9,421 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/admin/schedule/create" id="visitForm">
|
<form method="post" action="/admin/schedule/create" id="visitForm">
|
||||||
<!-- Client -->
|
|
||||||
<div class="field">
|
<!-- Client -->
|
||||||
<label class="label">{{ t.schedule_client }}</label>
|
<div class="field">
|
||||||
<div class="control">
|
<label class="label">{{ t.schedule_client }}</label>
|
||||||
<div class="select is-fullwidth">
|
<div class="control">
|
||||||
<select name="client_id" required>
|
<div class="select is-fullwidth">
|
||||||
<option value="">—</option>
|
<select name="client_id" required>
|
||||||
{% for c in &clients %}
|
<option value="">—</option>
|
||||||
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
|
{% for c in &clients %}
|
||||||
{% endfor %}
|
<option value="{{ c.id }}">{{ c.name }}{% if let Some(p) = c.phone.as_deref() %} ({{ p }}){% endif %}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin -->
|
<!-- Admin -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_admin }}</label>
|
<label class="label">{{ t.schedule_admin }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select name="user_id">
|
<select name="user_id">
|
||||||
{% for u in &users %}
|
{% for u in &users %}
|
||||||
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
|
<option value="{{ u.id }}" {% if u.id.unwrap() == current_user_id %}selected{% endif %}>
|
||||||
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
{{ u.display_name.as_deref().unwrap_or(&u.login) }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Default time -->
|
<!-- Default time -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_default_time }}</label>
|
<label class="label">{{ t.schedule_default_time }}</label>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<div class="time-row">
|
||||||
<div class="column">
|
<div class="time-block">
|
||||||
<div class="control">
|
<span class="time-lbl">{{ t.schedule_time_start }}</span>
|
||||||
<input class="input" type="time" id="defaultStart" value="18:00">
|
<input class="input" type="time" id="defaultStart" value="18:00">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="time-sep">—</div>
|
||||||
<div class="column">
|
<div class="time-block">
|
||||||
<div class="control">
|
<span class="time-lbl">{{ t.schedule_time_end }}</span>
|
||||||
<input class="input" type="time" id="defaultEnd" value="19:00">
|
<input class="input" type="time" id="defaultEnd" value="19:00">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add individual date -->
|
<!-- Calendar -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ t.schedule_pick_dates }}</label>
|
<div class="cal-toolbar">
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<button type="button" id="rangeModeBtn" class="button is-small is-outlined is-info">⇔ Выбрать диапазон</button>
|
||||||
<div class="column">
|
<button type="button" id="resetBtn" class="button is-small is-outlined is-danger" style="display:none;">✕ Сбросить</button>
|
||||||
<div class="control">
|
|
||||||
<input class="input" type="date" id="pickDate">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button type="button" class="button is-info" id="addDateBtn">+</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date range fill -->
|
<div class="sched-cal">
|
||||||
<div class="field">
|
<div class="cal-nav">
|
||||||
<label class="label is-small has-text-grey">{{ t.schedule_range_from }} — {{ t.schedule_range_to }}</label>
|
<button type="button" id="calPrev">◀</button>
|
||||||
<div class="columns is-mobile" style="margin-bottom:0;">
|
<span id="calTitle"></span>
|
||||||
<div class="column">
|
<button type="button" id="calNext">▶</button>
|
||||||
<input class="input" type="date" id="rangeFrom">
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<input class="input" type="date" id="rangeTo">
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button type="button" class="button is-info is-outlined" id="fillRangeBtn">{{ t.schedule_fill_range }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cal-grid" id="calGrid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Selected days list -->
|
<!-- Selected days -->
|
||||||
<div class="field">
|
<div class="field" id="selectedSection" style="display:none;">
|
||||||
<label class="label">{{ t.schedule_selected_days }}</label>
|
<label class="label">{{ t.schedule_selected_days }} <span id="selectedCount" class="tag is-info is-light" style="margin-left:0.4rem;"></span></label>
|
||||||
<div id="daysList">
|
<div id="daysList"></div>
|
||||||
<p class="has-text-grey is-size-7" id="noDaysMsg">{{ t.schedule_no_days }}</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.schedule_notes }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea" name="notes" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
||||||
<div class="field">
|
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
||||||
<label class="label">{{ t.schedule_notes }}</label>
|
|
||||||
<div class="control">
|
|
||||||
<textarea class="textarea" name="notes" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden days data -->
|
</form>
|
||||||
<input type="hidden" name="days_json" id="daysJson" value="[]">
|
|
||||||
|
|
||||||
<button type="submit" class="button is-primary is-fullwidth" id="submitBtn" disabled>{{ t.schedule_create }}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.day-row {
|
/* Time row */
|
||||||
display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0;
|
.time-row { display:flex; align-items:center; gap:0.5rem; }
|
||||||
border-bottom: 1px solid #f0f0f0; flex-wrap: wrap;
|
.time-block { display:flex; flex-direction:column; flex:1; }
|
||||||
}
|
.time-lbl { font-size:0.75rem; color:#888; margin-bottom:0.2rem; }
|
||||||
.day-row .day-date { font-weight: 600; min-width: 6rem; font-size: 0.9rem; }
|
.time-sep { font-size:1.2rem; color:#aaa; padding-top:1.2rem; }
|
||||||
.day-row input[type="time"] { width: 7rem; padding: 0.2rem 0.4rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85rem; }
|
|
||||||
.day-row .remove-btn { color: #e55; cursor: pointer; font-size: 0.8rem; margin-left: auto; background: none; border: none; }
|
/* Calendar toolbar */
|
||||||
|
.cal-toolbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.5rem; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Calendar container */
|
||||||
|
.sched-cal { background:#fafafa; border:1px solid #eee; border-radius:10px; overflow:hidden; }
|
||||||
|
|
||||||
|
.cal-nav { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 0.75rem; background:#fff; border-bottom:1px solid #eee; }
|
||||||
|
.cal-nav button { background:none; border:none; font-size:1.1rem; cursor:pointer; color:#6c63ff; padding:0.2rem 0.5rem; border-radius:4px; }
|
||||||
|
.cal-nav button:hover { background:#f0eeff; }
|
||||||
|
.cal-nav span { font-weight:700; font-size:1rem; color:#333; }
|
||||||
|
|
||||||
|
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); }
|
||||||
|
|
||||||
|
.cal-wday { text-align:center; font-size:0.72rem; font-weight:700; color:#aaa; padding:0.4rem 0; background:#fafafa; }
|
||||||
|
.cal-wday.is-weekend { color:#f0a0a0; }
|
||||||
|
|
||||||
|
.cal-day { text-align:center; padding:0.55rem 0.2rem; font-size:0.9rem; cursor:pointer; color:#333; border-radius:0; transition:background 0.1s; position:relative; user-select:none; -webkit-user-select:none; }
|
||||||
|
.cal-day:hover { background:#f0eeff; }
|
||||||
|
.cal-day.is-empty { cursor:default; }
|
||||||
|
.cal-day.is-empty:hover { background:none; }
|
||||||
|
.cal-day.is-today { font-weight:700; color:#6c63ff; }
|
||||||
|
.cal-day.is-selected { background:#6c63ff !important; color:#fff !important; border-radius:0; }
|
||||||
|
.cal-day.is-range-start { background:#a89cff !important; color:#fff !important; }
|
||||||
|
.cal-day.is-past { color:#ccc; }
|
||||||
|
|
||||||
|
/* Selected days list */
|
||||||
|
.day-row { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 0; border-bottom:1px solid #f5f5f5; }
|
||||||
|
.day-row:last-child { border-bottom:none; }
|
||||||
|
.day-date { font-weight:600; font-size:0.85rem; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.day-times { display:flex; align-items:center; gap:0.25rem; flex-shrink:0; }
|
||||||
|
.day-times .time-sep { color:#bbb; font-size:0.8rem; }
|
||||||
|
.day-rm { background:none; border:none; color:#ccc; cursor:pointer; font-size:1rem; padding:0.15rem 0.25rem; flex-shrink:0; line-height:1; }
|
||||||
|
.day-rm:hover { color:#e55; }
|
||||||
|
|
||||||
|
/* Time badge */
|
||||||
|
.time-badge-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #ede9ff;
|
||||||
|
color: #5b52d6;
|
||||||
|
border: 1.5px solid #c4beff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 3.6rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.time-badge-wrap:focus-within {
|
||||||
|
border-color: #6c63ff;
|
||||||
|
background: #f0eeff;
|
||||||
|
}
|
||||||
|
.time-badge-label { pointer-events: none; z-index: 1; }
|
||||||
|
.time-badge-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px; /* prevent iOS zoom */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const days = new Map(); // date string -> {start, end}
|
(function() {
|
||||||
const removeLabel = '{{ t.schedule_remove_day }}';
|
|
||||||
const weekdays = '{{ lang.code() }}' === 'ru'
|
const IS_RU = '{{ lang.code() }}' === 'ru';
|
||||||
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб']
|
const TZ = '{{ timezone }}';
|
||||||
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
const WDAYS = IS_RU ? ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] : ['Mo','Tu','We','Th','Fr','Sa','Su'];
|
||||||
|
const MONTHS = IS_RU
|
||||||
|
? ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
|
||||||
|
: ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
const days = new Map(); // dateStr -> {start, end}
|
||||||
|
let viewYear, viewMonth;
|
||||||
|
let rangeMode = false;
|
||||||
|
let rangeStart = null; // dateStr of first tap in range mode
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr_ = tzDateStr(now);
|
||||||
|
viewYear = parseInt(todayStr_.slice(0, 4));
|
||||||
|
viewMonth = parseInt(todayStr_.slice(5, 7)) - 1; // 0-based
|
||||||
|
|
||||||
|
function isoDate(y, m, d) {
|
||||||
|
return y + '-' + String(m+1).padStart(2,'0') + '-' + String(d).padStart(2,'0');
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaults() {
|
function getDefaults() {
|
||||||
return {
|
return {
|
||||||
start: document.getElementById('defaultStart').value || '18:00',
|
start: document.getElementById('defaultStart').value || '18:00',
|
||||||
end: document.getElementById('defaultEnd').value || '19:00'
|
end: document.getElementById('defaultEnd').value || '19:00'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDay(dateStr) {
|
function addDay(ds) {
|
||||||
if (!dateStr || days.has(dateStr)) return;
|
if (!ds || days.has(ds)) return;
|
||||||
const def = getDefaults();
|
const def = getDefaults();
|
||||||
days.set(dateStr, { start: def.start, end: def.end });
|
days.set(ds, { start: def.start, end: def.end });
|
||||||
renderDays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDay(dateStr) {
|
function toggleDay(ds) {
|
||||||
days.delete(dateStr);
|
if (days.has(ds)) { days.delete(ds); } else { addDay(ds); }
|
||||||
renderDays();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDays() {
|
// Получить текущую дату в нужном TZ как строку YYYY-MM-DD
|
||||||
const list = document.getElementById('daysList');
|
function tzDateStr(d) {
|
||||||
const msg = document.getElementById('noDaysMsg');
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
const btn = document.getElementById('submitBtn');
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
|
}).formatToParts(d);
|
||||||
|
const p = {};
|
||||||
|
parts.forEach(function(x) { p[x.type] = x.value; });
|
||||||
|
return p.year + '-' + p.month + '-' + p.day;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove old day rows
|
function fillRange(from, to) {
|
||||||
list.querySelectorAll('.day-row').forEach(el => el.remove());
|
if (from > to) { let t = from; from = to; to = t; }
|
||||||
|
// Используем полдень чтобы избежать проблем с переходом суток при смене DST
|
||||||
|
let cur = new Date(from + 'T12:00:00');
|
||||||
|
const end = new Date(to + 'T12:00:00');
|
||||||
|
while (cur <= end) {
|
||||||
|
addDay(tzDateStr(cur));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render calendar ──────────────────────────────────────
|
||||||
|
function renderCal() {
|
||||||
|
document.getElementById('calTitle').textContent = MONTHS[viewMonth] + ' ' + viewYear;
|
||||||
|
|
||||||
|
const grid = document.getElementById('calGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Weekday headers
|
||||||
|
WDAYS.forEach(function(wd, i) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cal-wday' + (i >= 5 ? ' is-weekend' : '');
|
||||||
|
cell.textContent = wd;
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
// First day of month (Mon=0 for our grid)
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
let startDow = first.getDay(); // 0=Sun
|
||||||
|
startDow = (startDow === 0) ? 6 : startDow - 1; // shift to Mon=0
|
||||||
|
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
const todayStr = todayStr_;
|
||||||
|
|
||||||
|
// Empty cells before first day
|
||||||
|
for (let i = 0; i < startDow; i++) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'cal-day is-empty';
|
||||||
|
grid.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const ds = isoDate(viewYear, viewMonth, d);
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
let cls = 'cal-day';
|
||||||
|
if (ds === todayStr) cls += ' is-today';
|
||||||
|
if (days.has(ds)) cls += ' is-selected';
|
||||||
|
if (ds === rangeStart) cls += ' is-range-start';
|
||||||
|
cell.className = cls;
|
||||||
|
cell.textContent = d;
|
||||||
|
cell.dataset.date = ds;
|
||||||
|
cell.addEventListener('click', onDayClick);
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDayClick(e) {
|
||||||
|
const ds = e.currentTarget.dataset.date;
|
||||||
|
if (!ds) return;
|
||||||
|
if (rangeMode) {
|
||||||
|
if (days.has(ds)) {
|
||||||
|
days.delete(ds);
|
||||||
|
if (rangeStart === ds) rangeStart = null;
|
||||||
|
} else if (!rangeStart) {
|
||||||
|
rangeStart = ds;
|
||||||
|
} else {
|
||||||
|
fillRange(rangeStart, ds);
|
||||||
|
rangeStart = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toggleDay(ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render selected days list ────────────────────────────
|
||||||
|
function renderList() {
|
||||||
|
const list = document.getElementById('daysList');
|
||||||
|
const section = document.getElementById('selectedSection');
|
||||||
|
const count = document.getElementById('selectedCount');
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById('resetBtn');
|
||||||
if (days.size === 0) {
|
if (days.size === 0) {
|
||||||
msg.style.display = '';
|
section.style.display = 'none';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
document.getElementById('daysJson').value = '[]';
|
document.getElementById('daysJson').value = '[]';
|
||||||
|
resetBtn.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msg.style.display = 'none';
|
resetBtn.style.display = '';
|
||||||
|
|
||||||
|
section.style.display = '';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
count.textContent = days.size;
|
||||||
|
|
||||||
// Sort by date
|
const sorted = [...days.entries()].sort((a,b) => a[0].localeCompare(b[0]));
|
||||||
const sorted = [...days.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
||||||
|
|
||||||
sorted.forEach(([dateStr, times]) => {
|
sorted.forEach(function([ds, times]) {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = new Date(ds + 'T00:00:00');
|
||||||
const wd = weekdays[d.getDay()];
|
const dow = IS_RU
|
||||||
const label = dateStr.split('-').reverse().join('.') + ' ' + wd;
|
? ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'][d.getDay()]
|
||||||
|
: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
||||||
|
const parts = ds.split('-');
|
||||||
|
const label = parts[2] + '.' + parts[1] + ' ' + dow; // DD.MM Вт
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'day-row';
|
row.className = 'day-row';
|
||||||
row.innerHTML = `
|
row.innerHTML =
|
||||||
<span class="day-date">${label}</span>
|
'<span class="day-date">' + label + '</span>' +
|
||||||
<input type="time" value="${times.start}" data-date="${dateStr}" data-field="start">
|
'<div class="day-times">' +
|
||||||
<span>—</span>
|
'<div class="time-badge-wrap">' +
|
||||||
<input type="time" value="${times.end}" data-date="${dateStr}" data-field="end">
|
'<span class="time-badge-label">' + times.start + '</span>' +
|
||||||
<button type="button" class="remove-btn" data-date="${dateStr}">${removeLabel}</button>
|
'<input type="time" class="time-badge-input" value="' + times.start + '" data-date="' + ds + '" data-field="start">' +
|
||||||
`;
|
'</div>' +
|
||||||
|
'<span class="time-sep">—</span>' +
|
||||||
|
'<div class="time-badge-wrap">' +
|
||||||
|
'<span class="time-badge-label">' + times.end + '</span>' +
|
||||||
|
'<input type="time" class="time-badge-input" value="' + times.end + '" data-date="' + ds + '" data-field="end">' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button type="button" class="day-rm" data-date="' + ds + '" title="Убрать">✕</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update hidden JSON
|
list.querySelectorAll('.time-badge-input').forEach(function(inp) {
|
||||||
updateJson();
|
|
||||||
|
|
||||||
// Bind events
|
|
||||||
list.querySelectorAll('input[type="time"]').forEach(inp => {
|
|
||||||
inp.addEventListener('change', function() {
|
inp.addEventListener('change', function() {
|
||||||
const dt = this.dataset.date;
|
const d = days.get(this.dataset.date);
|
||||||
const field = this.dataset.field;
|
if (d) {
|
||||||
if (days.has(dt)) {
|
d[this.dataset.field] = this.value;
|
||||||
days.get(dt)[field] = this.value;
|
this.previousElementSibling.textContent = this.value;
|
||||||
updateJson();
|
updateJson();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.remove-btn').forEach(btn => {
|
list.querySelectorAll('.day-rm').forEach(function(b) {
|
||||||
btn.addEventListener('click', function() {
|
b.addEventListener('click', function() {
|
||||||
removeDay(this.dataset.date);
|
days.delete(this.dataset.date);
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateJson() {
|
function updateJson() {
|
||||||
const arr = [...days.entries()].map(([date, t]) => ({
|
const arr = [...days.entries()].map(function([date, t]) {
|
||||||
date: date,
|
return { date: date, time_start: t.start, time_end: t.end };
|
||||||
time_start: t.start,
|
});
|
||||||
time_end: t.end
|
|
||||||
}));
|
|
||||||
document.getElementById('daysJson').value = JSON.stringify(arr);
|
document.getElementById('daysJson').value = JSON.stringify(arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('addDateBtn').addEventListener('click', function() {
|
// ── Navigation ───────────────────────────────────────────
|
||||||
const v = document.getElementById('pickDate').value;
|
document.getElementById('calPrev').addEventListener('click', function() {
|
||||||
addDay(v);
|
viewMonth--;
|
||||||
document.getElementById('pickDate').value = '';
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||||
|
renderCal();
|
||||||
|
});
|
||||||
|
document.getElementById('calNext').addEventListener('click', function() {
|
||||||
|
viewMonth++;
|
||||||
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||||
|
renderCal();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('pickDate').addEventListener('keydown', function(e) {
|
// ── Range mode toggle ────────────────────────────────────
|
||||||
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('addDateBtn').click(); }
|
document.getElementById('rangeModeBtn').addEventListener('click', function() {
|
||||||
});
|
rangeMode = !rangeMode;
|
||||||
|
rangeStart = null;
|
||||||
document.getElementById('fillRangeBtn').addEventListener('click', function() {
|
if (rangeMode) {
|
||||||
const from = document.getElementById('rangeFrom').value;
|
this.classList.remove('is-outlined', 'is-info');
|
||||||
const to = document.getElementById('rangeTo').value;
|
this.classList.add('is-warning');
|
||||||
if (!from || !to || from > to) return;
|
this.textContent = '✕ Выбрать отдельные дни';
|
||||||
let cur = new Date(from + 'T00:00:00');
|
} else {
|
||||||
const end = new Date(to + 'T00:00:00');
|
this.classList.remove('is-warning');
|
||||||
while (cur <= end) {
|
this.classList.add('is-outlined', 'is-info');
|
||||||
const ds = cur.toISOString().slice(0, 10);
|
this.textContent = '⇔ Выбрать диапазон';
|
||||||
addDay(ds);
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
}
|
||||||
document.getElementById('rangeFrom').value = '';
|
renderCal();
|
||||||
document.getElementById('rangeTo').value = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set default pick date to today
|
document.getElementById('resetBtn').addEventListener('click', function() {
|
||||||
document.getElementById('pickDate').valueAsDate = new Date();
|
days.clear();
|
||||||
|
rangeStart = null;
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Default time change → update existing days ───────────
|
||||||
|
// (only updates days that still have the old default)
|
||||||
|
// kept simple: doesn't retroactively update already-added days
|
||||||
|
|
||||||
|
renderCal();
|
||||||
|
renderList();
|
||||||
|
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user