Files
web-app/src/lib/pages/print/PrintUI.svelte

353 lines
15 KiB
Svelte
Raw Normal View History

2026-09-17 04:45:16 +03:00
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_label_has_associated_control slot_element_deprecated svelte_self_deprecated -->
2026-09-08 17:38:13 +03:00
<!-- lib/pages/print/PrintUI.svelte -->
<script>
2026-09-15 08:46:16 +03:00
import { QUALITIES, COLOR_MODES } from '$lib/common/utils/pricing.util.js';
import SliderOption from './SliderOption.svelte';
import ToggleOption from './ToggleOption.svelte';
import GearOption from './GearOption.svelte';
import FormatPicker from './FormatPicker.svelte';
const QUALITY_LABELS = QUALITIES.map((q) => q.label);
const COLOR_LABELS = COLOR_MODES.map((c) => c.label);
const DPI_OPTIONS = ['300', '600', 'Авто', '1200', '2400'];
const SIDES_OPTIONS = ['Односторонняя', 'Двусторонняя'];
const FORMAT_OPTIONS = ['Авто', 'A2', 'A3', 'A4', 'A5', '10x15'];
/** @type {'button' | 'icon-button' | 'counter' | 'toggle-group' | 'file-card' | 'upload-zone' | 'footer'} */
let { as, ...props } = $props();
let formatOpen = $state(false);
$effect(() => {
if (!props.file?.expanded) formatOpen = false;
});
function pagesText(file) {
const t = props.pagesLabel?.(file);
if (!t || t === 'Все') return 'Все страницы';
return t;
}
function formatText(file) {
if (!file.format || file.format === 'Авто') return `Авто (${file.autoFormat ?? 'A4'})`;
return file.format;
}
/** Является ли файл офисным документом */
function isOffice(file) {
return file?.fileType === 'office';
}
2026-09-19 03:05:36 +03:00
/** Можно ли разрешить двустороннюю печать для файла */
function canDuplex(file) {
if (!file) return false;
// Для office число страниц неизвестно.
// Если нужно блокировать и office, убери эту строку.
if (file.fileType === 'office') return true;
return (file.selectedPages?.size ?? 0) > 1;
}
2026-09-08 17:38:13 +03:00
</script>
{#if as === 'button'}
2026-09-15 08:46:16 +03:00
<button
class="action-btn {props.variant ?? 'primary'}"
disabled={props.disabled}
onclick={props.onclick}
type={props.type ?? 'button'}
>
<slot />
</button>
2026-09-08 17:38:13 +03:00
{:else if as === 'icon-button'}
2026-09-15 08:46:16 +03:00
<button
class="icon-btn"
class:danger={props.variant === 'danger'}
onclick={props.onclick}
title={props.title}
type="button"
><slot /></button>
2026-09-08 17:38:13 +03:00
{:else if as === 'counter'}
2026-09-15 08:46:16 +03:00
<div class="mini-counter">
<button class="circle-btn small" onclick={props.onDecrement} disabled={props.value <= (props.min ?? 1)} type="button"></button>
<span class="copies-value">{props.value} шт.</span>
<button class="circle-btn small" onclick={props.onIncrement} type="button"></button>
</div>
2026-09-08 17:38:13 +03:00
{:else if as === 'toggle-group'}
2026-09-15 08:46:16 +03:00
<div class="option-sub">
{#if props.label}<label class="option-title">{props.label}</label>{/if}
<div class="toggle-group horizontal">
{#each props.options as opt}
{@const val = props.valueKey ? opt[props.valueKey] : opt}
{@const lbl = props.labelKey ? opt[props.labelKey] : opt}
<button
type="button"
class="toggle-btn"
class:active={props.selected === val}
onclick={() => props.onSelect?.(val)}
>{lbl}</button>
{/each}
</div>
</div>
2026-09-08 17:38:13 +03:00
{:else if as === 'file-card'}
2026-09-15 08:46:16 +03:00
{@const file = props.file}
<section class="file-card" class:expanded={file.expanded}>
<div class="file-row" onclick={() => props.onToggle?.(file.id)}>
<span class="chevron">{file.expanded ? '▼' : '►'}</span>
<!-- Бейдж типа файла -->
{#if isOffice(file)}
<span class="type-badge">
{file.file.name.split('.').pop()?.toUpperCase() ?? 'DOC'}
</span>
{/if}
<span class="file-name" title={file.file.name}>{file.file.name}</span>
<span class="file-row-actions" onclick={(e) => e.stopPropagation()}>
<!-- Превью только для image/pdf -->
{#if file.previewUrl && !isOffice(file)}
<svelte:self as="icon-button" onclick={() => props.onPreview?.(file)} title="Предпросмотр">🔍</svelte:self>
{/if}
<svelte:self as="icon-button" variant="danger" onclick={() => props.onRemove?.(file.id)} title="Удалить"></svelte:self>
</span>
</div>
{#if file.expanded}
<div class="file-options">
{#if isOffice(file)}
<!-- ═══ Офисный документ: упрощённые настройки ═══ -->
<p class="office-hint">
Документ будет распечатан как есть — конвертация выполняется на сервере.
</p>
<div class="duo-group">
<div class="duo-row">
<ToggleOption
label="Печать"
options={SIDES_OPTIONS}
value={file.sides ?? 'Односторонняя'}
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
/>
<ToggleOption
label="Цветность"
options={COLOR_LABELS}
value={COLOR_LABELS[Math.max(0, COLOR_MODES.findIndex((c) => c.id === file.colorMode))]}
onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })}
/>
</div>
</div>
<div class="slider-options">
<SliderOption
label="Качество"
options={QUALITY_LABELS}
hint="Качество печати: влияет на расход тонера и время прогрева."
value={QUALITIES[file.qualityIndex]?.label ?? QUALITY_LABELS[1] ?? QUALITY_LABELS[0]}
onchange={(v) => props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })}
/>
</div>
{:else}
<!-- ═══ Image / PDF: полные настройки ═══ -->
<div class="duo-group">
<div class="duo-row">
<GearOption value={pagesText(file)} onclick={() => props.onPageSelect?.(file)} />
<GearOption value={formatText(file)} open={formatOpen} onclick={() => (formatOpen = !formatOpen)} />
</div>
<div class="duo-row">
2026-09-19 03:05:36 +03:00
<ToggleOption
label="Печать"
options={SIDES_OPTIONS}
value={file.sides ?? 'Односторонняя'}
disabled={!canDuplex(file)}
hint={canDuplex(file) ? '' : 'Двусторонняя доступна при выборе 2 и более страниц'}
onchange={(v) => props.onUpdate?.(file.id, { sides: v })}
/>
2026-09-15 08:46:16 +03:00
<ToggleOption
label="Цветность"
options={COLOR_LABELS}
value={COLOR_LABELS[Math.max(0, COLOR_MODES.findIndex((c) => c.id === file.colorMode))]}
onchange={(v, i) => props.onUpdate?.(file.id, { colorMode: COLOR_MODES[i]?.id })}
/>
</div>
</div>
<div class="slider-options">
<SliderOption
label="Качество"
options={QUALITY_LABELS}
hint="Качество печати: влияет на расход тонера и время прогрева."
value={QUALITIES[file.qualityIndex]?.label ?? QUALITY_LABELS[1] ?? QUALITY_LABELS[0]}
onchange={(v) => props.onUpdate?.(file.id, { qualityIndex: QUALITY_LABELS.indexOf(v) })}
/>
<SliderOption
label="DPI"
options={DPI_OPTIONS}
hint="Разрешение печати. «Авто» подбирает DPI по содержимому страницы."
value={file.dpi ?? 'Авто'}
onchange={(v) => props.onUpdate?.(file.id, { dpi: v })}
/>
</div>
{/if}
<!-- Количество копий — для всех типов -->
<div class="copies-row">
<label class="option-title compact">Кол-во:</label>
<svelte:self
as="counter"
value={file.copies}
min={1}
onIncrement={() => props.onUpdate?.(file.id, { copies: file.copies + 1 })}
onDecrement={() => props.onUpdate?.(file.id, { copies: Math.max(1, file.copies - 1) })}
/>
</div>
</div>
<!-- FormatPicker только для image/pdf -->
{#if !isOffice(file)}
<FormatPicker
bind:open={formatOpen}
options={FORMAT_OPTIONS}
selected={file.format ?? 'Авто'}
onselect={(v) => props.onUpdate?.(file.id, { format: v })}
/>
{/if}
{/if}
</section>
2026-09-08 17:38:13 +03:00
{:else if as === 'upload-zone'}
2026-09-15 08:46:16 +03:00
<div class="empty-upload">
<p class="empty-hint">Загрузите файлы для печати</p>
<svelte:self as="button" variant="primary" onclick={props.onSelect}>Загрузить файл(ы)</svelte:self>
</div>
2026-09-08 17:38:13 +03:00
{:else if as === 'footer'}
2026-09-15 08:46:16 +03:00
<footer class="actions">
{#if props.filesCount > 0}
<p class="file-count">
{props.filesCount} {props.plFiles(props.filesCount)}
{#if props.extraCopies > 0}, {props.extraCopies} {props.plCopies(props.extraCopies)}{/if}
</p>
{/if}
2026-09-19 03:05:36 +03:00
<div class="pricing-info">
<div class="pricing-row pricing-row-duplex">
<span class="price-tier" class:active={!!props.hasDuplex}>
Двусторонняя 15р
</span>
</div>
<div class="pricing-row">
<span class="price-tier" class:active={props.hasTierSmall}>
10р/л от 1
</span>
<span class="price-tier" class:active={props.hasTierMedium}>
9р/л от 25
</span>
</div>
</div>
2026-09-15 08:46:16 +03:00
<div class="price-summary">
<span class="summary-itogo">Итого</span>
<span class="summary-price">{props.totalPrice}</span>
</div>
<svelte:self as="button" variant="secondary" disabled={props.isPrinting || props.filesCount === 0} onclick={props.onSubmit}>
{props.isPrinting ? 'Отправка...' : 'Далее'}
</svelte:self>
</footer>
2026-09-08 17:38:13 +03:00
{/if}
<style>
2026-09-15 08:46:16 +03:00
.duo-group + .slider-options { margin-top: -1px; }
.duo-row { display: flex; gap: 5px; width: 100%; }
.duo-group { display: flex; flex-direction: column; gap: 6px; width: 100%; }
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
.action-btn:disabled { opacity: 0.6; cursor: wait; }
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99,102,241,0.5); }
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72,150,255,0.4); }
.icon-btn { width: 34px; height: 34px; border-radius: 10px; border: none; background: rgba(255,255,255,0.08); color: #f5f7fa; font-size: 14px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.icon-btn.danger { color: #ff6b6b; }
.mini-counter { display: flex; align-items: center; gap: 8px; }
.circle-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: #f5f7fa; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; padding: 0; line-height: 1; }
.circle-btn.small { width: 28px; height: 28px; font-size: 14px; }
.circle-btn:hover:not(:disabled) { background: rgba(99,102,241,0.2); border-color: #6366f1; }
.circle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.copies-value { font-size: 14px; font-weight: 600; color: #fff; min-width: 36px; text-align: center; font-variant-numeric: tabular-nums; }
.option-sub { display: flex; flex-direction: column; gap: 8px; }
.option-title { font-size: 13px; font-weight: 600; color: #8b93a1; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; display: block; }
.option-title.compact { margin-bottom: 0; font-size: 12px; white-space: nowrap; margin-right: 8px; }
.toggle-group { display: flex; gap: 10px; }
.toggle-btn { flex: 1; padding: 12px 8px; border-radius: 14px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.05); color: #f5f7fa; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.18s ease; }
.toggle-btn.active { background: rgba(99,102,241,0.25); border-color: #6366f1; color: #fff; }
.slider-options { display: flex; flex-direction: column; gap: 7px; width: 100%; }
.cycle-row { display: flex; gap: 10px; width: 100%; }
.copies-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.06); }
.file-card { border-radius: 16px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); overflow: hidden; padding-bottom: 6px; }
.file-row { width: 100%; box-sizing: border-box; display: flex; align-items: center; gap: 12px; padding: 14px; background: none; border: none; color: #f5f7fa; cursor: pointer; user-select: none; text-align: left; }
.file-row:hover { background: rgba(255,255,255,0.03); }
.chevron { color: #6366f1; font-size: 12px; transition: transform 0.15s; }
.file-name { flex: 1; font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-row-actions { display: flex; gap: 6px; }
.file-options { padding: 8px 14px 14px; display: flex; flex-direction: column; gap: 12px; }
.empty-upload { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; }
.empty-hint { color: #8b93a1; font-size: 15px; margin: 0; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
.file-count { color: #8b93a1; font-size: 13px; margin: 0; text-align: center; line-height: 1.4; }
2026-09-19 03:05:36 +03:00
.pricing-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 4px;
user-select: none;
}
.pricing-row {
display: flex;
justify-content: center;
gap: 12px;
font-size: 10px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.25);
flex-wrap: wrap;
}
.pricing-row-duplex {
margin-bottom: 2px;
}
2026-09-15 08:46:16 +03:00
.price-tier { transition: color 0.2s ease; }
.price-tier.active { color: rgba(255,255,255,0.6); }
.price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
.summary-price { font-size: 38px; font-weight: 800; color: #fff; }
/* ── Бейдж типа файла (DOC, XLS, ODT…) ── */
.type-badge {
flex-shrink: 0;
padding: 3px 7px;
border-radius: 6px;
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.35);
color: #a5b4fc;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.2;
user-select: none;
}
/* ── Подсказка для офисных файлов ── */
.office-hint {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.2);
color: #d4a94e;
font-size: 13px;
line-height: 1.5;
}
2026-09-08 17:38:13 +03:00
</style>