Files
Rozpisky/Rozpisky/ViewModels/MainViewModel.cs
T
marekandClaude Opus 5 cd2b82db3c Vlastní loga uživatele mimo instalační složku
Loga se dosud hledala jen v loga\ vedle .exe. Kdo si do číselníku přidal
firmu s vlastním logem, musel .dxf nakopírovat do instalační složky – tu ale
přeinstalace přepíše a v Program Files do ní nejde psát. Číselník firmu
přežil, logo ne.

Nově se hledá nejdřív v %APPDATA%\Rozpisky\loga a teprve pak mezi
dodávanými; nabídka v Údajích projektu je sjednocením obou složek (stejné
jméno se nezdvojuje). Tlačítko v Nastavení složku založí a otevře.

Hierarchie je stejná jako u číselníků a je to úmyslné: při konfliktu vyhrává
uživatelská vrstva. Kdyby vyhrávala dodávaná, uživatelova úprava by se tiše
neprojevila – on ji vidí uloženou, program pracuje s něčím jiným. Výjimkou
zůstávají data držící správnost výstupu (mapovani.json, Rozpiska.dxf), kde
vyhrává program.

Subagenti dostali explicitní model: render-dxf a wpf-ui sonnet,
verifikator haiku (jen spouští build a testy).

Build 0 chyb / 0 varování, 55 testů zelených (5 nových na slučování nabídky).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:13:13 +02:00

1418 lines
66 KiB
C#
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.
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Input;
using System.Windows.Threading;
using Microsoft.Win32;
using ACadSharp.IO;
using Rozpisky.Cad;
using Rozpisky.Data;
using Rozpisky.Dxf;
using Rozpisky.Models;
using Rozpisky.Rendering;
using Rozpisky.Views;
using Rozpisky.Xlsx;
namespace Rozpisky.ViewModels;
/// <summary>
/// Hlavní VM aplikace (ROZHRANI.md §4). Drží datový model v paměti (projekt, seznam
/// příloh, revize, číselníky, nastavení) a akce. Náhled a export do PDF/PNG jedou přes
/// renderovací engine (<see cref="Dxf.RozpiskaRenderer"/>).
/// </summary>
public sealed class MainViewModel : ObservableObject
{
public Projekt Projekt { get; } = new();
public Nastaveni Nastaveni { get; } = new();
public Ciselniky Ciselniky { get; } = new();
public ObservableCollection<Priloha> Prilohy { get; } = new();
public ObservableCollection<Revize> Revize { get; } = new();
// ---- CAD připojení (PODKLAD_CAD_Layout_Tisk.md §1) ----
/// <summary>Zástupná položka „Manuálně" v comboboxu CADu po výběru se zpřístupní textové pole
/// <see cref="RucniProgId"/> na vlastní ProgID (verzově konkrétní, např. „ZWCAD.Application.2023").
/// Je to sentinel (referenční porovnání), do <c>cad.json</c> se neukládá.</summary>
private static readonly CadEngine RucniPolozka = new() { ProgId = "", Nazev = "Manuálně (zadat ProgID)" };
/// <summary>Nabídka CADů v comboboxu = předpřipravené enginy z <c>cad.json</c> (databáze od
/// programátora, včetně verzově konkrétních jako „ZWCAD 2023") + zástupná položka „Manuálně".
/// CAD je vlastnost stroje, ne zakázky, takže výběr žije mimo projekt.</summary>
public ObservableCollection<CadEngine> PodporovaneCady { get; }
private CadEngine? _vybranyCad;
/// <summary>Aktuálně vybraný CAD engine v Nastavení. Změna se rovnou uloží do <c>cad.json</c>,
/// aby se výběr pamatoval napříč projekty i restarty aplikace.</summary>
public CadEngine? VybranyCad
{
get => _vybranyCad;
set
{
// WPF ComboBox při inicializaci / přegenerování ItemsSource krátce nastaví SelectedItem = null
// a přes TwoWay binding to zapíše sem to by přepsalo uložený výběr nullem (výběr se pak
// po restartu „ztratí"). Uživatel nemá jak vybrat „nic", takže null ignorujeme a vrátíme
// původní hodnotu zpět do UI (OnPropertyChanged bez zápisu do cad.json).
if (value is null)
{
if (_vybranyCad is not null) OnPropertyChanged();
return;
}
if (!SetField(ref _vybranyCad, value)) return;
OnPropertyChanged(nameof(JeRucniCad));
UlozCadEngines();
CommandManager.InvalidateRequerySuggested(); // CanExecute akcí závisí na výběru CADu
}
}
/// <summary>True, když je v comboboxu vybrané „Manuálně" řídí zpřístupnění textového pole ProgID.</summary>
public bool JeRucniCad => ReferenceEquals(VybranyCad, RucniPolozka);
private string _rucniProgId = "";
/// <summary>Ručně zadaný ProgID (aktivní jen v režimu „Manuálně"). Ukládá se do <c>cad.json</c>
/// jako vybraný ProgID, takže se po restartu obnoví i s režimem „Manuálně".</summary>
public string RucniProgId
{
get => _rucniProgId;
set { if (SetField(ref _rucniProgId, value)) { UlozCadEngines(); CommandManager.InvalidateRequerySuggested(); } }
}
/// <summary>ProgID, se kterým se skutečně připojuje: ručně zadaný v režimu „Manuálně", jinak
/// z vybraného enginu. Null/prázdné = není s čím se připojit (blokuje akce).</summary>
private string? EfektivniProgId => JeRucniCad
? (string.IsNullOrWhiteSpace(RucniProgId) ? null : RucniProgId.Trim())
: VybranyCad?.ProgId;
/// <summary>Popisek CADu do hlášek/stavu v režimu „Manuálně" samotný ProgID, jinak název enginu.</summary>
private string EfektivniNazev => JeRucniCad ? EfektivniProgId ?? "Manuálně" : VybranyCad?.Nazev ?? "CAD";
/// <summary>Je vybraný použitelný CAD (běžný engine, nebo „Manuálně" s vyplněným ProgID)?
/// Podmínka pro tlačítko „Připojit" i CAD akce.</summary>
public bool MaVybranyCad => !string.IsNullOrEmpty(EfektivniProgId);
/// <summary>Uloží seznam enginů (bez zástupné položky „Manuálně") + výběr do <c>cad.json</c>:
/// režim (combobox vs. Manuálně) i ruční text zvlášť, aby se po startu obnovil přesně stav UI.
/// Chyby zápisu (read-only složka) jen ohlásí.</summary>
private void UlozCadEngines()
{
OnPropertyChanged(nameof(MaVybranyCad));
try
{
var enginy = PodporovaneCady.Where(e => !ReferenceEquals(e, RucniPolozka)).ToList();
// Ruční text ukládáme vždy (i mimo režim „Manuálně"), ať zůstane poslední zadaná hodnota.
var vyber = new CadEnginesStore.Vyber(
JeRucniCad ? null : VybranyCad?.ProgId, JeRucniCad, RucniProgId);
CadEnginesStore.Save(enginy, vyber);
}
catch { StavovaZprava = "Výběr CADu se nepodařilo uložit do cad.json (složka jen pro čtení?)."; }
}
private string _cadStav = "Nepřipojeno";
/// <summary>Stav posledního pokusu o připojení na CAD (do UI pod tlačítkem „Připojit").</summary>
public string CadStav { get => _cadStav; private set => SetField(ref _cadStav, value); }
private Priloha? _selectedPriloha;
public Priloha? SelectedPriloha { get => _selectedPriloha; set => SetField(ref _selectedPriloha, value); }
/// <summary>Vícenásobný výběr řádků seznamu (pro „Exportuj výběr do PDF", hromadné smazání,
/// aktualizaci/tisk rozvržení). Plní ho View z řádků dotčených DataGrid.SelectedCells přes
/// <see cref="NastavVyber"/> (SelectionUnit=CellOrRowHeader → tah/Ctrl+klik vybírá buňky).</summary>
public ObservableCollection<Priloha> VybranePrilohy { get; } = new();
/// <summary>Převezme aktuální výběr řádků z View. Kromě naplnění <see cref="VybranePrilohy"/>
/// přepíná i <see cref="Priloha.JeVybrana"/> na dotčených řádcích z toho grid jemně podbarví
/// celé vybrané řádky (i při vícenásobném výběru tahem).</summary>
public void NastavVyber(IEnumerable<Priloha> vyber)
{
var novy = vyber.ToList();
// Zhasni příznak na řádcích, které už ve výběru nejsou (jinak by zvýraznění „zůstalo viset").
foreach (var p in VybranePrilohy) if (!novy.Contains(p)) p.JeVybrana = false;
VybranePrilohy.Clear();
foreach (var p in novy) { p.JeVybrana = true; VybranePrilohy.Add(p); }
}
/// <summary>Je co zpracovat na základě výběru? Vícenásobný výběr řádků, nebo aspoň aktivní řádek.
/// Sdílená podmínka pro CanExecute akcí nad přílohami (mazání, export, rozvržení).</summary>
private bool MaVyberPriloh => SelectedPriloha is not null || VybranePrilohy.Count > 0;
private string _stavovaZprava = "Připraveno";
/// <summary>Krátká informace ve stavovém řádku (např. průběh exportu „Exportuji rozpisku 1/41").</summary>
public string StavovaZprava { get => _stavovaZprava; private set => SetField(ref _stavovaZprava, value); }
private Revize? _selectedRevize;
public Revize? SelectedRevize { get => _selectedRevize; set => SetField(ref _selectedRevize, value); }
// ---- Výběry pro auto-fill (náhrada VLOOKUP, §4.2) ----
private Organizace? _selectedInvestor;
public Organizace? SelectedInvestor
{
get => _selectedInvestor;
set
{
if (!SetField(ref _selectedInvestor, value) || value is null) return;
Projekt.Investor = value.Nazev;
Projekt.InvestorAdresa = value.Adresa;
}
}
private Organizace? _selectedSlozkaSZ;
public Organizace? SelectedSlozkaSZ
{
get => _selectedSlozkaSZ;
set
{
if (!SetField(ref _selectedSlozkaSZ, value) || value is null) return;
Projekt.SlozkaSZ = value.Nazev;
Projekt.SlozkaSZAdresa = value.Adresa;
}
}
private Firma? _selectedZhotovitelSOD;
public Firma? SelectedZhotovitelSOD
{
get => _selectedZhotovitelSOD;
set
{
if (!SetField(ref _selectedZhotovitelSOD, value) || value is null) return;
Projekt.ZhotovitelSOD = value.Nazev;
Projekt.AdresaSOD = value.Adresa;
Projekt.TelSOD = value.Telefon;
Projekt.EmailSOD = value.Email;
}
}
private VerzeDokumentace? _selectedStupen;
public VerzeDokumentace? SelectedStupen
{
get => _selectedStupen;
set
{
if (!SetField(ref _selectedStupen, value) || value is null) return;
Projekt.StupenDokumentace = value.Hodnota;
}
}
// ---- Stav projektu / souboru ----
public int PocetPriloh => Prilohy.Count;
private string? _aktualniSoubor;
/// <summary>Cesta k aktuálně otevřenému souboru projektu (null = neuložený).</summary>
public string? AktualniSoubor
{
get => _aktualniSoubor;
set { if (SetField(ref _aktualniSoubor, value)) { OnPropertyChanged(nameof(NazevSouboru)); OnPropertyChanged(nameof(Titulek)); } }
}
/// <summary>Název souboru pro stavový řádek/titulek.</summary>
public string NazevSouboru => _aktualniSoubor is null ? "(neuložený projekt)" : Path.GetFileName(_aktualniSoubor);
/// <summary>Složka aktuálního .rzp (pro relativní cestu k orientačnímu schématu); null = neuložený projekt.</summary>
public string? RzpSlozka => string.IsNullOrEmpty(_aktualniSoubor) ? null : Path.GetDirectoryName(_aktualniSoubor);
/// <summary>Sestaví mapu „hladina placeholderu → cesta k DXF" (loga + schéma) pro danou přílohu.</summary>
public IReadOnlyDictionary<string, string> ResolveLoga(Priloha r)
=> LogaResolver.Resolve(Projekt, r, Ciselniky, RzpSlozka);
/// <summary>Jména DXF log z obou složek <c>loga\</c> uživatelské i dodávané (prázdná položka
/// = „žádné") nabídka pro výběr log financujících institucí v Údajích projektu.</summary>
public IReadOnlyList<string> DostupnaLoga
{
get
{
var list = new List<string> { "" };
list.AddRange(LogaResolver.DostupnaLoga());
return list;
}
}
private bool _jeZmeneno;
/// <summary>Příznak neuložených změn (dirty flag). Nastavují ho automaticky změny modelu;
/// shazuje se po uložení, načtení a založení nového projektu.</summary>
public bool JeZmeneno
{
get => _jeZmeneno;
private set { if (SetField(ref _jeZmeneno, value)) OnPropertyChanged(nameof(Titulek)); }
}
private bool _jenProCteni;
/// <summary>Projekt je otevřený jen pro čtení (soubor drží jiný proces). „Uložit" se chová
/// jako „Uložit jako" nelze přepsat cizí zamčený soubor.</summary>
public bool JenProCteni
{
get => _jenProCteni;
private set { if (SetField(ref _jenProCteni, value)) OnPropertyChanged(nameof(Titulek)); }
}
/// <summary>Titulek hlavního okna: hvězdička = neuložené změny, pak název souboru a případně příznak režimu.</summary>
public string Titulek =>
$"{(JeZmeneno ? "*" : "")}{NazevSouboru}{(JenProCteni ? " [jen pro čtení]" : "")} Rozpisky generátor rozpisek";
/// <summary>Exkluzivní zámek aktuálně otevřeného souboru (drží se po celou dobu otevření,
/// aby ho jiný proces nemohl smazat/přepsat). Null = neuložený projekt bez souboru.</summary>
private ProjektFileHandle? _souborHandle;
// ---- Příkazy ----
public ICommand NovyProjektCmd { get; }
public ICommand OtevritProjektCmd { get; }
public ICommand UlozitProjektCmd { get; }
public ICommand UlozitJakoProjektCmd { get; }
public ICommand PridejPrilohuCmd { get; }
public ICommand DuplikujPrilohuCmd { get; }
public ICommand SmazPrilohuCmd { get; }
public ICommand PosunPrilohuNahoruCmd { get; }
public ICommand PosunPrilohuDoluCmd { get; }
public ICommand NahledPrilohyCmd { get; }
public ICommand VyberSchemaCmd { get; }
public ICommand PridejReviziCmd { get; }
public ICommand SmazReviziCmd { get; }
/// <summary>Žádost o otevření náhledu vybrané přílohy. Okno otvírá View (zná Window/Owner),
/// VM jen drží enable/disable a SelectedPriloha.</summary>
public event Action? NahledPozadovan;
/// <summary>Šířky sloupců gridu „Seznam příloh" (px) ve fixním pořadí sloupců. UI stav ukládaný
/// do .rzp; plní ho <see cref="Views.SeznamPrilohView"/> při ručním roztažení sloupce a čte při
/// obnově. Prázdný = výchozí šířky z XAML.</summary>
public List<double> SirkySloupcuSeznamu { get; private set; } = new();
/// <summary>Žádost, aby View promítl <see cref="SirkySloupcuSeznamu"/> do sloupců gridu (po načtení
/// projektu / novém projektu).</summary>
public event Action? SirkySloupcuObnoveny;
/// <summary>View sem hlásí aktuální šířky po ručním roztažení sloupce. Změna = neuložená změna
/// projektu (uloží se pak spolu s daty). Shoda s uloženým stavem (i po obnově) je no-op, takže
/// promítnutí načtených šířek do gridu projekt zpět „nešpiní".</summary>
public void NastavSirkySloupcuSeznamu(IEnumerable<double> sirky)
{
var nove = sirky.ToList();
if (nove.SequenceEqual(SirkySloupcuSeznamu)) return;
SirkySloupcuSeznamu = nove;
JeZmeneno = true;
}
public ICommand ExportVyberPdfCmd { get; }
public ICommand ExportVsePdfCmd { get; }
public ICommand ExportVyberDxfCmd { get; }
public ICommand ExportVseDxfCmd { get; }
public ICommand ZrusitExportCmd { get; }
public ICommand PripojitCadCmd { get; }
public ICommand AktualizujRozvrzeniCmd { get; }
public ICommand VytiskniRozvrzeniCmd { get; }
public ICommand TiskSeznamuCmd { get; }
public ICommand ExportSeznamuCmd { get; }
public ICommand OtevritNapoveduCmd { get; }
public ICommand OtevritManualCmd { get; }
/// <summary>Otevře v Průzkumníku složku, kam si uživatel dává vlastní loga.</summary>
public ICommand OtevritSlozkuLogCmd { get; }
public ICommand OProgramuCmd { get; }
public MainViewModel()
{
var (cadEngines, vyber) = CadEnginesStore.Load();
// Combobox = předpřipravené enginy + zástupná položka „Manuálně" na konci.
PodporovaneCady = new ObservableCollection<CadEngine>(cadEngines) { RucniPolozka };
// Ruční text se drží vždy (i mimo režim „Manuálně"), aby po přepnutí zpět na „Manuálně"
// i po restartu zůstala poslední ručně zadaná hodnota.
_rucniProgId = vyber.RucniProgId ?? "";
_vybranyCad = vyber.RucniRezim
? RucniPolozka
: cadEngines.FirstOrDefault(e => e.ProgId == vyber.VybranyProgId);
Prilohy.CollectionChanged += (_, _) => OnPropertyChanged(nameof(PocetPriloh));
NovyProjektCmd = new RelayCommand(NovyProjekt);
OtevritProjektCmd = new RelayCommand(OtevritProjekt);
UlozitProjektCmd = new RelayCommand(() => Uloz(AktualniSoubor));
UlozitJakoProjektCmd = new RelayCommand(() => Uloz(null));
PridejPrilohuCmd = new RelayCommand(() =>
{
var p = new Priloha();
Prilohy.Add(p);
SelectedPriloha = p;
});
DuplikujPrilohuCmd = new RelayCommand(
() =>
{
if (SelectedPriloha is null) return;
var kopie = SelectedPriloha.Klon();
Prilohy.Insert(Prilohy.IndexOf(SelectedPriloha) + 1, kopie);
SelectedPriloha = kopie;
},
() => SelectedPriloha is not null);
SmazPrilohuCmd = new RelayCommand(
() =>
{
// Snapshot: Prilohy.Remove spustí SelectedCellsChanged → NastavVyber přepíše VybranePrilohy,
// přes kterou bychom jinak iterovali.
var kSmazani = (VybranePrilohy.Count > 0
? VybranePrilohy.AsEnumerable()
: SelectedPriloha is not null ? new[] { SelectedPriloha } : Enumerable.Empty<Priloha>())
.ToList();
foreach (var p in kSmazani) Prilohy.Remove(p);
SelectedPriloha = null;
},
() => MaVyberPriloh);
PosunPrilohuNahoruCmd = new RelayCommand(
() => PosunPrilohu(-1),
() => SelectedPriloha is not null && Prilohy.IndexOf(SelectedPriloha) > 0);
PosunPrilohuDoluCmd = new RelayCommand(
() => PosunPrilohu(+1),
() => SelectedPriloha is not null && Prilohy.IndexOf(SelectedPriloha) is var i && i >= 0 && i < Prilohy.Count - 1);
NahledPrilohyCmd = new RelayCommand(
() => NahledPozadovan?.Invoke(),
() => SelectedPriloha is not null);
VyberSchemaCmd = new RelayCommand(VyberSchema, () => SelectedPriloha is not null);
PridejReviziCmd = new RelayCommand(() =>
{
var r = new Revize();
Revize.Add(r);
SelectedRevize = r;
});
SmazReviziCmd = new RelayCommand(
() => { if (SelectedRevize is not null) Revize.Remove(SelectedRevize); },
() => SelectedRevize is not null);
ExportVyberPdfCmd = new RelayCommand(
() => _ = ExportujDoPdf(VyberKExportu()),
() => MaVyberPriloh);
ExportVsePdfCmd = new RelayCommand(
() => _ = ExportujDoPdf(Prilohy.ToList()),
() => Prilohy.Count > 0);
ExportVyberDxfCmd = new RelayCommand(
() => _ = ExportujDoDxf(VyberKExportu()),
() => MaVyberPriloh);
ExportVseDxfCmd = new RelayCommand(
() => _ = ExportujDoDxf(Prilohy.ToList()),
() => Prilohy.Count > 0);
ZrusitExportCmd = new RelayCommand(
() => { _exportZrusen = true; StavovaZprava = "Ruším export…"; },
() => ExportBezi);
PripojitCadCmd = new RelayCommand(PripojNaCad, () => MaVybranyCad);
AktualizujRozvrzeniCmd = new RelayCommand(
() => _ = AktualizovatRozvrzeni(),
() => MaVybranyCad && MaVyberPriloh);
VytiskniRozvrzeniCmd = new RelayCommand(
() => _ = VytisknoutRozvrzeni(),
() => MaVybranyCad && MaVyberPriloh);
TiskSeznamuCmd = new RelayCommand(TisknoutSeznamPdf, () => Prilohy.Count > 0);
ExportSeznamuCmd = new RelayCommand(ExportujSeznamDoXlsx, () => Prilohy.Count > 0);
OtevritNapoveduCmd = new RelayCommand(OtevritNapovedu);
OtevritManualCmd = new RelayCommand(OtevritManual);
OtevritSlozkuLogCmd = new RelayCommand(OtevritSlozkuLog);
OProgramuCmd = new RelayCommand(OProgramu);
SledujZmeny();
NactiCiselniky();
DemoData.SeedProjekt(this);
JeZmeneno = false; // úvodní data nejsou „neuložená změna"
}
// ---- Sledování neuložených změn (dirty flag) ----
/// <summary>Naváže se na změny modelu (Projekt, Nastavení, položky příloh/revizí i jejich kolekce),
/// aby se při jakékoli editaci nastavil <see cref="JeZmeneno"/>. Číselníky se nesledují ukládají
/// se zvlášť vedle .exe.</summary>
private void SledujZmeny()
{
Projekt.PropertyChanged += (_, _) => JeZmeneno = true;
Nastaveni.PropertyChanged += (_, _) => JeZmeneno = true;
Prilohy.CollectionChanged += KolekceZmenena;
Revize.CollectionChanged += KolekceZmenena;
}
/// <summary>Položky přihlášené k <see cref="PolozkaZmenena"/> podle kolekce. Clear() vyvolá
/// Reset **bez** OldItems, takže odhlašované položky nejde vzít z eventu bez této evidence by
/// řádky starého projektu zůstaly přihlášené a (držené třeba náhledem) dál špinily dirty flag.</summary>
private readonly Dictionary<object, List<ObservableObject>> _sledovanePolozky = new();
/// <summary>Přidání/odebrání řádku je změna; nové řádky navíc začne sledovat po buňkách.</summary>
private void KolekceZmenena(object? sender, NotifyCollectionChangedEventArgs e)
{
var sledovane = _sledovanePolozky.TryGetValue(sender!, out var s)
? s : _sledovanePolozky[sender!] = new();
if (e.Action == NotifyCollectionChangedAction.Reset)
{
foreach (var o in sledovane) o.PropertyChanged -= PolozkaZmenena;
sledovane.Clear();
// Reset nemusí znamenat jen Clear přihlaš, co v kolekci případně zůstalo/přibylo.
foreach (ObservableObject o in (System.Collections.IEnumerable)sender!)
{
o.PropertyChanged += PolozkaZmenena;
sledovane.Add(o);
}
}
else
{
if (e.OldItems is not null)
foreach (ObservableObject o in e.OldItems)
{
o.PropertyChanged -= PolozkaZmenena;
sledovane.Remove(o);
}
if (e.NewItems is not null)
foreach (ObservableObject o in e.NewItems)
{
o.PropertyChanged += PolozkaZmenena;
sledovane.Add(o);
}
}
JeZmeneno = true;
if (ReferenceEquals(sender, Prilohy)) PrepocitejDuplicity();
}
private void PolozkaZmenena(object? sender, PropertyChangedEventArgs e)
{
// Čistě odvozené / UI příznaky nejsou data projektu přeskoč je, jinak by pouhé kliknutí
// do buňky (výběr řádku → JeVybrana) nebo přepočet kolizí (JeDuplicitniJmeno) označilo
// projekt jako neuložený, aniž se cokoli změnilo.
if (sender is Priloha && e.PropertyName is nameof(Priloha.JeVybrana) or nameof(Priloha.JeDuplicitniJmeno))
return;
JeZmeneno = true;
// Změna buňky přílohy může změnit jméno rozvržení → přepočítat kolize.
if (sender is Priloha)
PrepocitejDuplicity();
}
/// <summary>Označí řádky, jejichž „Jméno rozvržení" se shoduje s jiným řádkem (kolize názvu
/// exportu). Prázdná jména se neporovnávají, porovnání je case-insensitive.</summary>
private void PrepocitejDuplicity()
{
var pocty = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var p in Prilohy)
{
var jmeno = p.JmenoRozvrzeni;
if (string.IsNullOrWhiteSpace(jmeno)) continue;
pocty[jmeno] = pocty.TryGetValue(jmeno, out var n) ? n + 1 : 1;
}
foreach (var p in Prilohy)
p.JeDuplicitniJmeno = !string.IsNullOrWhiteSpace(p.JmenoRozvrzeni)
&& pocty.TryGetValue(p.JmenoRozvrzeni, out var n) && n > 1;
}
/// <summary>Posune vybranou přílohu v seznamu o <paramref name="smer"/> (1 nahoru, +1 dolů).
/// Řazení kliknutím na hlavičku je vypnuté, pořadí se mění jen těmito tlačítky.</summary>
private void PosunPrilohu(int smer)
{
if (SelectedPriloha is null) return;
int i = Prilohy.IndexOf(SelectedPriloha);
int j = i + smer;
if (i < 0 || j < 0 || j >= Prilohy.Count) return;
Prilohy.Move(i, j); // přesun zachová referenci → řádek zůstane vybraný
SelectedPriloha = Prilohy[j];
}
// ---- Sdílené číselníky (mimo projekt, vedle .exe) ----
/// <summary>Načte číselníky: dodávaná vrstva vedle .exe + rozdíl uživatele z jeho složky
/// (<see cref="CiselnikyStore"/>). Pak zapne autosave při přidání/odebrání položky.</summary>
private void NactiCiselniky()
{
CiselnikyStore.Nacti(Ciselniky);
// Autosave na přidání/odebrání položky; úpravy v buňkách se uloží při zavření okna.
foreach (var col in new INotifyCollectionChanged[]
{ Ciselniky.Organizace, Ciselniky.Firmy, Ciselniky.Jmena, Ciselniky.VerzeDokumentace, Ciselniky.Casti, Ciselniky.Kraje })
col.CollectionChanged += (_, _) => UlozCiselniky();
}
/// <summary>Uloží rozdíl číselníků proti dodávané vrstvě do složky uživatele. Při autosave
/// záměrně bez dialogu (nesmí spamovat MessageBoxy) selhání se ale ukáže ve stavovém řádku,
/// ať uživatel ví, že se změny číselníků neukládají. Volá se i při zavření okna.</summary>
public void UlozCiselniky()
{
try { CiselnikyStore.UlozRozdil(Ciselniky); }
catch { StavovaZprava = $"Číselníky se nepodařilo uložit do složky {UzivatelskaData.Slozka}."; }
}
/// <summary>„Samoučení" našeptávače: jména použitá v projektu/přílohách, která v číselníku
/// Jména ještě nejsou, do něj doplní (a tím přes autosave uloží) nabídka tak roste z používání.</summary>
private void NaucSeJmena()
{
void Pridej(string? s)
{
s = s?.Trim();
if (string.IsNullOrEmpty(s)) return;
if (Ciselniky.Jmena.Any(j => string.Equals(j.Hodnota, s, StringComparison.OrdinalIgnoreCase))) return;
Ciselniky.Jmena.Add(new Jmeno { Hodnota = s }); // CollectionChanged → autosave
}
Pridej(Projekt.HIP);
foreach (var p in Prilohy)
{
Pridej(p.Vypracoval); Pridej(p.OdpovednyProjektant); Pridej(p.Specialista);
Pridej(p.KontrolovalR1); Pridej(p.KontrolovalR2); Pridej(p.KontrolovalR3); Pridej(p.KontrolovalR4);
}
}
// ---- Ukládání / načítání projektu (ROZHRANI.md §9) ----
/// <summary>Snímek aktuálního stavu projektu pro uložení (číselníky se neukládají).</summary>
private ProjektDocument BuildDocument() => new()
{
Projekt = Projekt,
Nastaveni = Nastaveni,
Prilohy = Prilohy.ToList(),
Revize = Revize.ToList(),
SirkySloupcuSeznamu = SirkySloupcuSeznamu.ToList(),
};
/// <summary>Naplní živý model z načteného dokumentu (zachová databinding).</summary>
private void ApplyDocument(ProjektDocument doc)
{
RozpiskaRenderer.VycistiCache(); // jiný projekt = jiná loga/schémata; ať cache neroste a nedrží staré verze
Projekt.CopyFrom(doc.Projekt);
Nastaveni.CopyFrom(doc.Nastaveni);
Prilohy.Clear();
foreach (var p in doc.Prilohy) Prilohy.Add(p);
Revize.Clear();
foreach (var r in doc.Revize) Revize.Add(r);
PrepocitejDuplicity();
// Dohledat odpovídající položky číselníků pro comboboxy (podle uloženého názvu/hodnoty).
SelectedInvestor = Ciselniky.Organizace.FirstOrDefault(o => o.Nazev == Projekt.Investor);
SelectedSlozkaSZ = Ciselniky.Organizace.FirstOrDefault(o => o.Nazev == Projekt.SlozkaSZ);
SelectedZhotovitelSOD = Ciselniky.Firmy.FirstOrDefault(f => f.Nazev == Projekt.ZhotovitelSOD);
SelectedStupen = Ciselniky.VerzeDokumentace.FirstOrDefault(v => v.Hodnota == Projekt.StupenDokumentace);
// Uložené šířky sloupců seznamu → View je promítne do gridu (prázdný seznam = výchozí z XAML).
SirkySloupcuSeznamu = doc.SirkySloupcuSeznamu.ToList();
SirkySloupcuObnoveny?.Invoke();
}
private void NovyProjekt()
{
if (!PotvrditZahozeniZmen()) return;
ApplyDocument(new ProjektDocument());
SelectedInvestor = SelectedSlozkaSZ = null;
SelectedZhotovitelSOD = null;
SelectedStupen = null;
SelectedPriloha = null;
SelectedRevize = null;
UvolniSoubor(); // nový projekt nemá soubor → pusť případný zámek
AktualniSoubor = null;
JenProCteni = false;
JeZmeneno = false;
}
private void OtevritProjekt()
{
if (!PotvrditZahozeniZmen()) return;
var dlg = new OpenFileDialog { Filter = ProjektStore.DialogFilter, DefaultExt = ProjektStore.Pripona };
if (dlg.ShowDialog() != true) return;
// Při znovuotevření téhož souboru nejdřív pusť vlastní zámek (jinak by si appka
// sama bránila v otevření přes FileShare.Read).
if (string.Equals(_souborHandle?.Cesta, dlg.FileName, StringComparison.OrdinalIgnoreCase))
UvolniSoubor();
try
{
var handle = ProjektFileHandle.Otevri(dlg.FileName, out var doc);
UvolniSoubor(); // starý zámek pusť až po úspěšném otevření nového
_souborHandle = handle;
ApplyDocument(doc);
AktualniSoubor = dlg.FileName;
JenProCteni = false;
JeZmeneno = false;
}
catch (IOException)
{
// Soubor drží jiný proces → nabídni otevření jen pro čtení (bez zámku).
if (MessageBox.Show(
"Soubor je používán jiným programem nebo instancí, takže ho nelze otevřít pro úpravy.\n\n" +
"Chcete ho otevřít jen pro čtení?",
"Soubor je používán", MessageBoxButton.YesNo, MessageBoxImage.Question) != MessageBoxResult.Yes)
return;
try
{
var doc = ProjektFileHandle.CtiJenProCteni(dlg.FileName);
UvolniSoubor();
ApplyDocument(doc);
AktualniSoubor = dlg.FileName;
JenProCteni = true;
JeZmeneno = false;
}
catch (Exception ex)
{
MessageBox.Show($"Projekt se nepodařilo načíst:\n{ex.Message}",
"Chyba", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
catch (Exception ex)
{
MessageBox.Show($"Projekt se nepodařilo načíst:\n{ex.Message}",
"Chyba", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>Uloží projekt. Když <paramref name="cesta"/> je null, zeptá se na soubor (Uložit jako).
/// Vrací true při úspěšném uložení, false při zrušení dialogu nebo chybě (pro guard při zavírání).</summary>
private bool Uloz(string? cesta)
{
// Režim jen pro čtení: původní soubor drží jiný proces → „Uložit" musí jít přes „Uložit jako".
if (JenProCteni && cesta is not null && string.Equals(cesta, AktualniSoubor, StringComparison.OrdinalIgnoreCase))
cesta = null;
if (cesta is null)
{
var dlg = new SaveFileDialog
{
Filter = ProjektStore.DialogFilter,
DefaultExt = ProjektStore.Pripona,
// Už uložený projekt (i „jen pro čtení") → předvyplň jeho původní název i složku, ať
// „Uložit jako" nabízí přejmenování/kopii vedle originálu. Neuložený → číslo zakázky,
// jinak neutrální „projekt".
FileName = !string.IsNullOrEmpty(AktualniSoubor)
? Path.GetFileNameWithoutExtension(AktualniSoubor)
: string.IsNullOrWhiteSpace(Projekt.CisloZakazky) ? "projekt" : Projekt.CisloZakazky,
InitialDirectory = RzpSlozka ?? "",
};
if (dlg.ShowDialog() != true) return false;
cesta = dlg.FileName;
}
try
{
NaucSeJmena(); // doplň nová jména do číselníku z údajů projektu/příloh
var doc = BuildDocument();
if (string.Equals(_souborHandle?.Cesta, cesta, StringComparison.OrdinalIgnoreCase))
{
_souborHandle!.Uloz(doc); // zápis přes držený zámek
}
else
{
var handle = ProjektFileHandle.Vytvor(cesta, doc); // „Uložit jako" na novou cestu
UvolniSoubor(); // starý zámek pusť až po úspěchu
_souborHandle = handle;
}
AktualniSoubor = cesta;
JenProCteni = false; // od teď máme vlastní zapisovatelný soubor se zámkem
JeZmeneno = false;
return true;
}
catch (IOException ex)
{
MessageBox.Show($"Soubor nelze uložit cílový soubor je nejspíš otevřený v jiném programu:\n{ex.Message}",
"Soubor je používán", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
catch (Exception ex)
{
MessageBox.Show($"Projekt se nepodařilo uložit:\n{ex.Message}",
"Chyba", MessageBoxButton.OK, MessageBoxImage.Error);
return false;
}
}
/// <summary>Uvolní zámek aktuálního souboru (zavření projektu/appky).</summary>
public void UvolniSoubor()
{
_souborHandle?.Dispose();
_souborHandle = null;
}
/// <summary>Při neuložených změnách se zeptá, zda uložit (Ano/Ne/Storno). Vrací true, když je
/// bezpečné pokračovat (uloženo nebo zahodit), false při Storno / zrušeném ukládání → akci přeruš.</summary>
public bool PotvrditZahozeniZmen()
{
if (!JeZmeneno) return true;
var odpoved = MessageBox.Show(
$"Projekt „{NazevSouboru}\" obsahuje neuložené změny.\nChcete je před pokračováním uložit?",
"Neuložené změny", MessageBoxButton.YesNoCancel, MessageBoxImage.Warning);
return odpoved switch
{
MessageBoxResult.Yes => Uloz(AktualniSoubor), // false, když uživatel zruší Uložit jako
MessageBoxResult.No => true,
_ => false, // Storno
};
}
// ---- Dávkový export rozpisek do PDF (ROZHRANI.md §3.5) ----
/// <summary>Podsložky zakládané v cílové složce exportu. Vygenerovaná PDF jdou do „Rozpisky".</summary>
private static readonly string[] ExportPodslozky =
{ "__ Otevřená", "__ Uzavřená", "+Přílohy s rozpiskami", "Přílohy", "Rozpisky" };
/// <summary>Přílohy pro „Exportuj výběr do PDF": vícenásobný výběr řádků (v pořadí seznamu),
/// nebo aktuálně vybraný řádek, když je výběr prázdný.</summary>
private IReadOnlyList<Priloha> VyberKExportu()
{
var vyber = VybranePrilohy.Count > 0
? VybranePrilohy.AsEnumerable()
: SelectedPriloha is not null ? new[] { SelectedPriloha } : Enumerable.Empty<Priloha>();
return vyber.OrderBy(Prilohy.IndexOf).ToList(); // exportuj odshora dolů dle pořadí seznamu
}
private bool _exportuje;
/// <summary>Běží dávkový export (PDF/DXF) řídí viditelnost tlačítka „Zrušit export“ ve stavovém řádku.</summary>
public bool ExportBezi { get => _exportuje; private set => SetField(ref _exportuje, value); }
/// <summary>Požadavek na zrušení běžícího exportu kontroluje se mezi položkami dávky
/// (rozdělaná položka se vždy dokončí).</summary>
private bool _exportZrusen;
/// <summary>Ověří kořenovou cestu exportu (musí existovat) a vytvoří podsložku exportu i vnitřní
/// strukturu složek (<see cref="ExportPodslozky"/>). Vrací základ exportu (kořen/podsložka), nebo
/// <c>null</c> při chybě (sám zobrazí hlášení s titulkem <paramref name="titulek"/>).</summary>
private string? PripravZakladExportu(string titulek)
{
var koren = Nastaveni.CestaExportu?.Trim();
if (string.IsNullOrWhiteSpace(koren))
{
MessageBox.Show("Není zadaná kořenová cesta exportu (Nastavení → EXPORT).",
titulek, MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
if (!Directory.Exists(koren))
{
MessageBox.Show($"Kořenová cesta exportu neexistuje:\n{koren}",
titulek, MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}
try
{
var zaklad = string.IsNullOrWhiteSpace(Nastaveni.SlozkaExportu)
? koren
: Path.Combine(koren, Nastaveni.SlozkaExportu.Trim());
foreach (var s in ExportPodslozky)
Directory.CreateDirectory(Path.Combine(zaklad, s));
return zaklad;
}
catch (Exception ex)
{
MessageBox.Show($"Strukturu složek exportu se nepodařilo vytvořit:\n{ex.Message}",
titulek, MessageBoxButton.OK, MessageBoxImage.Error);
return null;
}
}
/// <summary>Vyexportuje zadané přílohy do PDF do struktury složek pod kořenovou cestou
/// (Nastavení → EXPORT). Kořenová cesta musí existovat; podsložka exportu i vnitřní struktura
/// (<see cref="ExportPodslozky"/>) se v případě potřeby vytvoří. Průběh hlásí stavový řádek
/// (<see cref="StavovaZprava"/>). Když cílové PDF drží jiný program, zeptá se Pokračovat/Přerušit
/// (<see cref="ZamcenySouborWindow"/>).</summary>
/// <summary>Načte renderer nad aktuální šablonou rozpisky. Při chybě (chybějící/poškozená šablona)
/// zobrazí hlášení s titulkem <paramref name="titulek"/> a vrátí false. Sdílené exportem do PDF i DXF.</summary>
private bool TryVytvorRenderer(string titulek, out RozpiskaRenderer renderer)
{
try { renderer = new RozpiskaRenderer(Nastaveni.SablonaDxf); return true; }
catch (Exception ex)
{
MessageBox.Show($"Šablonu rozpisky se nepodařilo načíst:\n{ex.Message}\n\n" +
$"Šablona: {RozpiskaRenderer.ResolvePath(Nastaveni.SablonaDxf)}",
titulek, MessageBoxButton.OK, MessageBoxImage.Error);
renderer = null!;
return false;
}
}
private async Task ExportujDoPdf(IReadOnlyList<Priloha> prilohy)
{
if (ExportBezi) return; // neběž dvakrát naráz
if (prilohy.Count == 0)
{
MessageBox.Show("Není co exportovat nic není vybráno nebo je seznam příloh prázdný.",
"Export do PDF", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
if (!PotvrditDuplicitniNazvy(prilohy, "Export do PDF")) return;
var zaklad = PripravZakladExportu("Export do PDF");
if (zaklad is null) return;
var cilRozpisky = Path.Combine(zaklad, "Rozpisky");
if (!TryVytvorRenderer("Export do PDF", out var renderer)) return;
NaucSeJmena(); // jména z příloh se zařadí do našeptávače
ExportBezi = true;
_exportZrusen = false;
int hotovo = 0, bezJmena = 0, n = prilohy.Count, index = 0;
var chyby = new List<string>();
try
{
foreach (var p in prilohy)
{
if (_exportZrusen) break; // „Zrušit export“ ve stavovém řádku
index++;
var nazev = SestavNazevSouboru(p);
if (nazev is null) { bezJmena++; continue; }
StavovaZprava = $"Exportuji rozpisku {index}/{n}: {nazev}";
await Dispatcher.Yield(DispatcherPriority.Background); // nech stavový řádek překreslit
var cesta = Path.Combine(cilRozpisky, nazev + ".pdf");
// PDF je otevřené v jiném programu → Pokračovat (zkusit znovu) / Přerušit (zastav export).
if (!PockejNaUvolneniSouboru(cesta)) break;
try
{
renderer.ExportPdf(Projekt, p, Revize, Ciselniky, cesta, ResolveLoga(p));
hotovo++;
}
catch (Exception ex)
{
chyby.Add($"{nazev}.pdf {ex.Message}");
}
}
}
finally
{
ExportBezi = false;
StavovaZprava = "Připraveno";
}
ZobrazSouhrnExportu(cilRozpisky, hotovo, bezJmena, chyby);
}
/// <summary>Mezera mezi rozpiskami v dlaždicovém DXF (výkresové jednotky).</summary>
private const double DxfMezera = 10;
/// <summary>Počet rozpisek v jedné řadě dlaždicového DXF (pak se zalomí do dalšího řádku).</summary>
private const int DxfSloupcu = 10;
/// <summary>Vyexportuje zadané přílohy do <b>jednoho</b> DXF (<c>_rozpisky.dxf</c> ve složce
/// <c>__ Otevřená</c>), rozpisky poskládané do mřížky vedle sebe s mezerou <see cref="DxfMezera"/>.
/// Rozměr buňky bere z celkového <c>!!NOPRINT</c> rámečku šablony. Atributy jsou ve výsledku
/// TEXT/MTEXT (<see cref="DxfProfileRenderer"/>). Když je cílový soubor používán, zeptá se
/// Pokračovat/Přerušit.</summary>
private async Task ExportujDoDxf(IReadOnlyList<Priloha> prilohy)
{
if (ExportBezi) return;
if (prilohy.Count == 0)
{
MessageBox.Show("Není co exportovat nic není vybráno nebo je seznam příloh prázdný.",
"Export do DXF", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var zaklad = PripravZakladExportu("Export do DXF");
if (zaklad is null) return;
var cesta = Path.Combine(zaklad, "__ Otevřená", "_rozpisky.dxf");
if (!TryVytvorRenderer("Export do DXF", out var renderer)) return;
if (renderer.CellFrame is not { } cell || cell.W <= 0 || cell.H <= 0)
{
MessageBox.Show("Šablona nemá rámeček (!!NOPRINT / A4) nelze určit rozměr rozpisky pro mřížku.",
"Export do DXF", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
NaucSeJmena();
ExportBezi = true;
_exportZrusen = false;
int hotovo = 0, n = prilohy.Count;
int cols = Math.Min(DxfSloupcu, Math.Max(1, n));
var dxf = new DxfProfileRenderer();
var chyby = new List<string>();
try
{
for (int i = 0; i < n; i++)
{
if (_exportZrusen) { StavovaZprava = "Připraveno"; return; } // bez zápisu částečného DXF
StavovaZprava = $"Generuji rozpisku {i + 1}/{n}";
await Dispatcher.Yield(DispatcherPriority.Background);
int col = i % cols, row = i / cols;
double ox = col * (cell.W + DxfMezera);
double oy = -row * (cell.H + DxfMezera); // další řádky pod sebe (osa Y nahoru)
try
{
var draw = renderer.BuildDraw(Projekt, prilohy[i], Revize, Ciselniky, vyplnene: true,
ResolveLoga(prilohy[i]), ramecekA4: true);
// Generálka naprázdno: výjimka uprostřed kreslení by ve sdíleném dokumentu
// nechala torzo rozpisky (entity přidané před pádem nejde vzít zpět). Do
// skutečného DXF se tak kreslí až kresba, která celá prošla.
draw(new BoundsRenderer());
draw(new TransformRenderer(dxf, 1.0, ox, oy));
hotovo++;
}
catch (Exception ex)
{
var jmeno = string.IsNullOrWhiteSpace(prilohy[i].JmenoRozvrzeni) ? $"#{i + 1}" : prilohy[i].JmenoRozvrzeni;
chyby.Add($"{jmeno} {ex.Message} (rozpiska v DXF není)");
}
}
StavovaZprava = "Zapisuji DXF…";
await Dispatcher.Yield(DispatcherPriority.Background);
// Soubor používán jiným programem → Pokračovat (zkusit znovu) / Přerušit.
if (!PockejNaUvolneniSouboru(cesta))
{
StavovaZprava = "Připraveno";
return;
}
DxfWriter.Write(cesta, dxf.Document, binary: true); // binární DXF = výrazně menší soubor
}
catch (Exception ex)
{
MessageBox.Show($"DXF se nepodařilo zapsat:\n{ex.Message}",
"Export do DXF", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
finally
{
ExportBezi = false;
StavovaZprava = "Připraveno";
}
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Vyexportováno rozpisek do DXF: {hotovo} z {n}");
if (chyby.Count > 0)
{
sb.AppendLine($"Chyby: {chyby.Count}");
foreach (var ch in chyby) sb.AppendLine($" • {ch}");
}
sb.AppendLine();
sb.Append($"Soubor: {cesta}");
MessageBox.Show(sb.ToString(), "Export do DXF dokončen", MessageBoxButton.OK,
chyby.Count > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information);
}
// ---- Export seznamu příloh objektu do XLSX (Otevřená verze, ROZHRANI.md §5) ----
/// <summary>Vyexportuje „Seznam příloh" zvoleného objektu do .xlsx (vyplní mustr) vedle souboru
/// projektu. Projekt musí být uložený (jinak není kam ukládat). Objekt se vybírá v dialogu.</summary>
private void ExportujSeznamDoXlsx()
{
// Export jde vedle .rzp → projekt musí být uložený (a bez neuložených změn).
if (string.IsNullOrEmpty(AktualniSoubor))
{
MessageBox.Show("Projekt zatím není uložený. Seznam se ukládá vedle souboru projektu, " +
"proto projekt nejdřív uložte.",
"Export seznamu", MessageBoxButton.OK, MessageBoxImage.Information);
if (!Uloz(null)) return; // uživatel uložení zrušil
}
else if (JeZmeneno && !Uloz(AktualniSoubor)) return; // ulož změny, ať seznam odpovídá
if (string.IsNullOrEmpty(AktualniSoubor)) return; // pojistka
// Výběr objektu + revize do kódu seznamu.
var objekty = ObjektyProVyber();
if (objekty.Count == 0)
{
MessageBox.Show("V seznamu příloh není žádný objekt (vyplněné „Číslo objektu/komplexu“).",
"Export seznamu", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var revizeMozne = RevizeProVyber();
var vyber = new VyberObjektuWindow(objekty, revizeMozne.Count > 0 ? revizeMozne : null)
{ Owner = Application.Current?.MainWindow };
if (vyber.ShowDialog() != true || vyber.Vybrane is not { } cisloObjektu) return;
var revizeKod = vyber.VybranaRevize;
var prilohyObjektu = Prilohy
.Where(p => string.Equals(p.CisloObjektu?.Trim(), cisloObjektu, StringComparison.OrdinalIgnoreCase))
.ToList();
if (Path.GetDirectoryName(AktualniSoubor) is not { Length: > 0 } slozkaRzp)
{
MessageBox.Show($"Ze souboru projektu se nepodařilo určit složku pro seznam:\n{AktualniSoubor}",
"Export seznamu", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
var cil = Path.Combine(slozkaRzp, "SEZNAM_" + ObjektOznaceni.Alfanum(cisloObjektu) + ".xlsx");
// Cílový .xlsx je otevřený v Excelu → Pokračovat (zkusit znovu) / Přerušit.
if (!PockejNaUvolneniSouboru(cil)) return;
var mustr = RozpiskaRenderer.ResolvePath(Nastaveni.SeznamMustr);
if (!File.Exists(mustr))
{
MessageBox.Show($"Mustr seznamu (.xlsx) nebyl nalezen:\n{mustr}",
"Export seznamu", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
try
{
// Pozice 134 kódu jsou objektové (libovolná příloha objektu), revize = volba uživatele.
var kod = KodPrilohy.SestavSeznam(Projekt, prilohyObjektu[0], revizeKod);
SeznamExporter.Export(mustr, cil, Projekt, cisloObjektu, prilohyObjektu, kod);
MessageBox.Show($"Seznam příloh objektu {cisloObjektu} byl vyexportován:\n{cil}",
"Export seznamu dokončen", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Export seznamu se nezdařil:\n{ex.Message}",
"Export seznamu", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>Čísla objektů projektu (distinct, seřazená) pro výběr.</summary>
private List<string> ObjektyProVyber() => Prilohy
.Select(p => (p.CisloObjektu ?? "").Trim())
.Where(s => s.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.ToList();
/// <summary>Nabídka revizí do kódu seznamu: kódy z tabulky revizí projektu, jinak revize
/// použité v přílohách (R1R4).</summary>
private List<string> RevizeProVyber()
{
var z = Revize.Select(r => (r.Cislo ?? "").Trim()).Where(s => s.Length > 0);
if (!z.Any())
z = Prilohy.SelectMany(p => new[] { p.R1, p.R2, p.R3, p.R4 })
.Select(s => (s ?? "").Trim()).Where(s => s.Length > 0);
return z.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>Nabídne výběr objektu projektu (combobox čísel objektů). Vrací zvolené číslo objektu,
/// nebo null (nic k výběru / uživatel zrušil). Používá tisk seznamu (bez výběru revize).</summary>
private string? VyberObjekt(string titulek)
{
var objekty = ObjektyProVyber();
if (objekty.Count == 0)
{
MessageBox.Show("V seznamu příloh není žádný objekt (vyplněné „Číslo objektu/komplexu“).",
titulek, MessageBoxButton.OK, MessageBoxImage.Information);
return null;
}
var dlg = new VyberObjektuWindow(objekty) { Owner = Application.Current?.MainWindow };
return dlg.ShowDialog() == true ? dlg.Vybrane : null;
}
/// <summary>„Tisk seznamu": vytiskne dříve vyexportovaný seznam objektu (SEZNAM_*.xlsx vedle .rzp)
/// do PDF přes MS Excel; PDF jde do podsložky „Přílohy" ve struktuře exportu.</summary>
private void TisknoutSeznamPdf()
{
if (string.IsNullOrEmpty(AktualniSoubor))
{
MessageBox.Show("Projekt zatím není uložený. Seznam (.xlsx) se hledá vedle souboru projektu, " +
"proto projekt nejdřív uložte (a vyexportujte seznam).",
"Tisk seznamu", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
if (VyberObjekt("Tisk seznamu") is not { } cisloObjektu) return;
if (Path.GetDirectoryName(AktualniSoubor) is not { Length: > 0 } slozkaRzp)
{
MessageBox.Show($"Ze souboru projektu se nepodařilo určit složku se seznamem:\n{AktualniSoubor}",
"Tisk seznamu", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
var xlsx = Path.Combine(slozkaRzp, "SEZNAM_" + ObjektOznaceni.Alfanum(cisloObjektu) + ".xlsx");
if (!File.Exists(xlsx))
{
MessageBox.Show($"Seznam pro objekt {cisloObjektu} zatím neexistuje:\n{xlsx}\n\n" +
"Nejdřív použijte „Export seznamu“.",
"Tisk seznamu", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var zaklad = PripravZakladExportu("Tisk seznamu");
if (zaklad is null) return;
// Výsledné PDF dle manuálu: objekt_[podobjekt]_XX_X_001 (seznam = „příloha 001", typ XX zůstává).
// XLSX mustr zůstává pojmenovaný SEZNAM_<objekt>.xlsx (vedle .rzp).
var pdf = Path.Combine(zaklad, "Přílohy", Priloha.NazevObjektovehoSpecialu(cisloObjektu, "001") + ".pdf");
if (!PockejNaUvolneniSouboru(pdf)) return;
try
{
ExcelToPdf.Convert(xlsx, pdf);
MessageBox.Show($"Seznam objektu {cisloObjektu} byl vytištěn do PDF:\n{pdf}",
"Tisk seznamu dokončen", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Tisk seznamu do PDF se nezdařil:\n{ex.Message}",
"Tisk seznamu", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>Název PDF souboru rozpisky: „Jméno rozvržení" (sloupec seznamu) + u zaškrtnutého
/// sloupce „Rozpiska" (<see cref="Priloha.JeRozpiska"/>) přípona „_rozpiska". Příznak je
/// samostatný, nezávislý na „Příloha objektu"/„Desky objektu" i rozpiska na desky objektu
/// může příponu dostat. Neplatné znaky názvu souboru se nahradí podtržítkem. Vrací null, když
/// řádek nemá jméno rozvržení (přeskočí se).</summary>
private static string? SestavNazevSouboru(Priloha p) => SestavNazevSouboru(p, sPriponouRozpiska: true);
/// <summary>Sestaví název souboru z <see cref="Priloha.JmenoRozvrzeni"/> (ořez, neplatné znaky → „_“).
/// Přípona „_rozpiska“ (u <see cref="Priloha.JeRozpiska"/>) se přidává jen při exportu rozpisky
/// (<paramref name="sPriponouRozpiska"/> = true); tisk samotného rozvržení z CADu ji nepřidává
/// je to název layoutu, ne rozpisky.</summary>
private static string? SestavNazevSouboru(Priloha p, bool sPriponouRozpiska)
{
var jmeno = p.JmenoRozvrzeni?.Trim();
if (string.IsNullOrWhiteSpace(jmeno)) return null;
if (sPriponouRozpiska && p.JeRozpiska) jmeno += "_rozpiska";
foreach (var c in Path.GetInvalidFileNameChars())
jmeno = jmeno.Replace(c, '_');
return jmeno;
}
/// <summary>Kontrola před exportem do PDF: přílohy se stejným výsledným názvem souboru by si PDF
/// navzájem přepsaly (soubor by zůstal jen od poslední z nich). Grid kolize sice podbarvuje
/// (<see cref="Priloha.JeDuplicitniJmeno"/>), ale při „Exportuj vše" je nemusí mít uživatel před
/// očima. Vrací true = pokračovat (žádné kolize, nebo je uživatel vědomě potvrdil).</summary>
private static bool PotvrditDuplicitniNazvy(IReadOnlyList<Priloha> prilohy, string titulek)
{
var kolize = prilohy
.Select(SestavNazevSouboru)
.Where(n => n is not null)
.GroupBy(n => n!, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => $"{g.Key}.pdf ({g.Count()}×)")
.ToList();
if (kolize.Count == 0) return true;
return MessageBox.Show(
"Některé exportované přílohy mají stejné „Jméno rozvržení“, takže se jejich PDF navzájem " +
"přepíšou a zůstane jen poslední z nich:\n\n • " + string.Join("\n • ", kolize) +
"\n\nChcete přesto pokračovat?",
titulek, MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes;
}
/// <summary>Když cílové PDF drží jiný program, opakovaně se ptá Pokračovat/Přerušit, dokud se
/// soubor neuvolní nebo uživatel nezvolí Přerušit. Vrací true = lze zapsat, false = přeruš export.</summary>
private static bool PockejNaUvolneniSouboru(string cesta)
{
while (JeSouborZamceny(cesta))
{
var dlg = new ZamcenySouborWindow(cesta) { Owner = Application.Current?.MainWindow };
if (dlg.ShowDialog() != true) return false; // Přerušit
}
return true;
}
/// <summary>Zjistí, zda existující soubor drží jiný proces (nelze ho otevřít pro výhradní zápis).</summary>
private static bool JeSouborZamceny(string cesta)
{
if (!File.Exists(cesta)) return false;
try
{
using var fs = new FileStream(cesta, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
return false;
}
catch (IOException) { return true; }
catch (UnauthorizedAccessException) { return true; }
}
/// <summary>Souhrn dávkového exportu (počet hotových, přeskočených bez jména, chyby) + cílová složka.</summary>
private static void ZobrazSouhrnExportu(string cilRozpisky, int hotovo, int bezJmena, IReadOnlyList<string> chyby)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Vyexportováno do PDF: {hotovo}");
if (bezJmena > 0) sb.AppendLine($"Přeskočeno (bez jména rozvržení): {bezJmena}");
if (chyby.Count > 0)
{
sb.AppendLine($"Chyby: {chyby.Count}");
foreach (var ch in chyby) sb.AppendLine($" • {ch}");
}
sb.AppendLine();
sb.Append($"Složka: {cilRozpisky}");
MessageBox.Show(sb.ToString(), "Export do PDF dokončen", MessageBoxButton.OK,
chyby.Count > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information);
}
/// <summary>Vybere DXF orientačního schématu pro vybranou přílohu. Leží-li soubor ve složce
/// aktuálního .rzp, uloží se relativně (jen jméno), jinak absolutní cestou.</summary>
private void VyberSchema()
{
if (SelectedPriloha is null) return;
var dlg = new OpenFileDialog
{
Filter = "Výkres DXF (*.dxf)|*.dxf",
DefaultExt = ".dxf",
InitialDirectory = RzpSlozka ?? "",
};
if (dlg.ShowDialog() != true) return;
var dir = RzpSlozka;
SelectedPriloha.SchemaSoubor =
dir is not null && string.Equals(Path.GetDirectoryName(dlg.FileName), dir, StringComparison.OrdinalIgnoreCase)
? Path.GetFileName(dlg.FileName) // ve složce .rzp → relativně
: dlg.FileName; // jinde → absolutní cesta
}
/// <summary>Ověří připojení na vybraný CAD (jen attach na běžící instanci, nic nespouští) a
/// výsledek zapíše do <see cref="CadStav"/>. Spojení se hned po ověření zase uvolní držení
/// živého <see cref="CadLink"/> napříč akcemi přijde s dalším krokem (úprava rozvržení).</summary>
private void PripojNaCad()
{
if (EfektivniProgId is not { } progId) return;
using var filtr = ComMessageFilter.Registruj(); // retry, když je CAD zrovna zaneprázdněný
using var cad = new CadLink();
if (!cad.Connect(progId, EfektivniNazev))
{
CadStav = cad.LastError ?? $"Připojení na {EfektivniNazev} selhalo.";
return;
}
string nazevVykresu;
try { nazevVykresu = cad.Doc!.Name; }
catch { nazevVykresu = "?"; }
CadStav = $"Připojeno: {cad.Engine} výkres {nazevVykresu}";
}
private bool _cadOperaceBezi;
/// <summary>„Aktualizuj rozvržení" (PODKLAD_CAD_Layout_Tisk.md §4): pro vybrané řádky seznamu
/// přejmenuje odpovídající rozvržení výkresu, vyplní blok hlavičky, spočítá počet papírů z rámečku,
/// vyhodnotí měřítko z výřezů a nastaví tiskárnu vše přes živé spojení na vybraný CAD (na rozdíl
/// od <see cref="PripojNaCad"/> zůstává spojení držené po celou dobu akce, ne jen na ověření).
/// Průběh a chyby jdou do nemodálního <see cref="LayoutLogWindow"/>; zápis <c>Meritko</c>/<c>Formaty</c>
/// zpět proběhne přímo na instancích z <see cref="Prilohy"/>, takže se v mřížce projeví automaticky.</summary>
private async Task AktualizovatRozvrzeni()
{
if (_cadOperaceBezi || !MaVybranyCad) return;
var vybrane = VyberKExportu();
if (vybrane.Count == 0)
{
MessageBox.Show("Není vybrán žádný řádek seznamu (vyberte řádky v Seznamu příloh).",
"Aktualizuj rozvržení", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var log = new LayoutLogWindow("Aktualizuji rozvržení") { Owner = Application.Current?.MainWindow };
log.Show();
_cadOperaceBezi = true;
try
{
using var filtr = ComMessageFilter.Registruj(); // retry, když je CAD zrovna zaneprázdněný
using var cad = new CadLink();
if (!cad.Connect(EfektivniProgId!, EfektivniNazev))
{
log.Chyba(cad.LastError ?? $"Připojení na {EfektivniNazev} selhalo.");
return;
}
StavovaZprava = "Aktualizuji rozvržení…";
var cfg = LayoutConfigStore.Load();
// cad.Rodina (z ProgID), ne cad.Engine (popisný název): layout.json páruje tiskárnu/CTB
// podle rodiny CADu, ne podle verzovaného/ručního názvu enginu z cad.json.
await CadLayoutService.AktualizujRozvrzeni(cad.Doc!, cad.Rodina, vybrane, Projekt, Nastaveni, cfg, log, log.Token);
}
catch (Exception ex)
{
log.Chyba($"Neočekávaná chyba: {ex.Message}");
}
finally
{
_cadOperaceBezi = false;
StavovaZprava = "Připraveno";
}
}
/// <summary>„Vytiskni rozvržení" (PODKLAD_CAD_Layout_Tisk.md §5): vytiskne rozvržení vybraných
/// příloh do PDF do podsložky exportu „Přílohy" (tiskne se celá příloha z CADu na rozdíl od
/// exportu samotné rozpisky, který jde do „Rozpisky"). S exportem do PDF sdílí chování při
/// zamčeném cíli (<see cref="PockejNaUvolneniSouboru"/>); název souboru je jméno rozvržení bez
/// přípony „_rozpiska".</summary>
private async Task VytisknoutRozvrzeni()
{
// Breadcrumb hned na začátku (viz CadTiskService.Diag → tisk_diag.log): rozlišuje pád „hned
// po kliknutí“ (v tisk_diag.log jen tenhle řádek) od pádu až při tisku (řádky ze CadTiskService).
CadTiskService.Diag($"Klik „Vytiskni rozvržení“, CAD = {(MaVybranyCad ? EfektivniNazev : "(žádný)")}");
if (_cadOperaceBezi || !MaVybranyCad) return;
var vybrane = VyberKExportu();
if (vybrane.Count == 0)
{
MessageBox.Show("Není vybrán žádný řádek seznamu (vyberte řádky v Seznamu příloh).",
"Vytiskni rozvržení", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var zaklad = PripravZakladExportu("Vytiskni rozvržení");
if (zaklad is null) return;
var cilPrilohy = Path.Combine(zaklad, "Přílohy"); // tiskne se celá příloha, ne rozpiska
var log = new LayoutLogWindow("Tisknu rozvržení") { Owner = Application.Current?.MainWindow };
log.Show();
_cadOperaceBezi = true;
try
{
using var filtr = ComMessageFilter.Registruj(); // retry, když je CAD zrovna zaneprázdněný
using var cad = new CadLink();
if (!cad.Connect(EfektivniProgId!, EfektivniNazev))
{
log.Chyba(cad.LastError ?? $"Připojení na {EfektivniNazev} selhalo.");
return;
}
StavovaZprava = "Tisknu rozvržení…";
// cad.Doc je dynamic, takže se celé volání váže dynamicky metody je proto nutné
// předat jako explicitně otypované delegáty, ne jako pouhé skupiny metod (CS1976).
// Tisk rozvržení z CADu nepřidává příponu „_rozpiska“ je to název layoutu (na rozdíl
// od exportu rozpisky do PDF, který ji přidává).
Func<Priloha, string?> sestavNazev = p => SestavNazevSouboru(p, sPriponouRozpiska: false);
Func<string, bool> pockejNaUvolneni = PockejNaUvolneniSouboru;
await CadTiskService.VytiskniRozvrzeni(cad.Doc!, vybrane, cilPrilohy, Nastaveni, log,
sestavNazev, pockejNaUvolneni, log.Token);
}
catch (Exception ex)
{
log.Chyba($"Neočekávaná chyba: {ex.Message}");
}
finally
{
_cadOperaceBezi = false;
StavovaZprava = "Připraveno";
}
}
private static void OProgramu() => MessageBox.Show(
"Rozpisky generátor výkresových rohových razítek\n\n" +
"Standalone náhrada za původní excelový nástroj \"Rozpisky 1.0.8\".",
"O programu", MessageBoxButton.OK, MessageBoxImage.Information);
/// <summary>Otevře vestavěnou nápovědu k programu (samostatné okno). DataContext = tento VM,
/// aby tlačítko „Otevřít PDF manuál" uvnitř okna mohlo bindovat na <see cref="OtevritManualCmd"/>.</summary>
private void OtevritNapovedu()
{
var okno = new NapovedaWindow { Owner = Application.Current?.MainWindow, DataContext = this };
okno.ShowDialog();
}
/// <summary>Otevře originální manuál SŽ (PDF) uložený vedle .exe ve výchozím prohlížeči PDF.
/// Při chybějícím souboru jen upozorní (nespadne).</summary>
private static void OtevritManual()
{
var cesta = Path.Combine(AppContext.BaseDirectory, "SZ_SM011_P10_Manual_v6.pdf");
if (!File.Exists(cesta))
{
MessageBox.Show("Manuál nebyl nalezen vedle programu:\n" + cesta,
"Nápověda", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
try
{
Process.Start(new ProcessStartInfo(cesta) { UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show("Manuál se nepodařilo otevřít:\n" + ex.Message,
"Nápověda", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>Otevře v Průzkumníku složku pro uživatelská loga (a založí ji, když ještě není).
/// Vlastní loga patří sem, ne do složky programu tu přeinstalace přepíše.</summary>
private void OtevritSlozkuLog()
{
var slozka = LogaResolver.UzivatelskaLogaDir;
try
{
Directory.CreateDirectory(slozka);
Process.Start(new ProcessStartInfo(slozka) { UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show("Složku s logy se nepodařilo otevřít:\n" + slozka + "\n\n" + ex.Message,
"Loga", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}