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

353 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_label_has_associated_control slot_element_deprecated svelte_self_deprecated -->
<!-- lib/pages/print/PrintUI.svelte -->
<script>
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';
}
/** Можно ли разрешить двустороннюю печать для файла */
function canDuplex(file) {
if (!file) return false;
// Для office число страниц неизвестно.
// Если нужно блокировать и office, убери эту строку.
if (file.fileType === 'office') return true;
return (file.selectedPages?.size ?? 0) > 1;
}
</script>
{#if as === 'button'}
<button
class="action-btn {props.variant ?? 'primary'}"
disabled={props.disabled}
onclick={props.onclick}
type={props.type ?? 'button'}
>
<slot />
</button>
{:else if as === 'icon-button'}
<button
class="icon-btn"
class:danger={props.variant === 'danger'}
onclick={props.onclick}
title={props.title}
type="button"
><slot /></button>
{:else if as === 'counter'}
<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>
{:else if as === 'toggle-group'}
<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>
{:else if as === 'file-card'}
{@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">
<ToggleOption
label="Печать"
options={SIDES_OPTIONS}
value={file.sides ?? 'Односторонняя'}
disabled={!canDuplex(file)}
hint={canDuplex(file) ? '' : 'Двусторонняя доступна при выборе 2 и более страниц'}
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) })}
/>
<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>
{:else if as === 'upload-zone'}
<div class="empty-upload">
<p class="empty-hint">Загрузите файлы для печати</p>
<svelte:self as="button" variant="primary" onclick={props.onSelect}>Загрузить файл(ы)</svelte:self>
</div>
{:else if as === 'footer'}
<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}
<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>
<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>
{/if}
<style>
.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; }
.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;
}
.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;
}
</style>