Historia faktur

Dzisiaj przedstawię niektóre elementy strony historii faktur. Słowo „niektóre” pojawia się dlatego, że strona ta czerpie garściami z innych – zaimplementowanych już i opisanych. Od razu pokażę jej wygląd, aby Czytelnik, czytając o kolejnych elementach, znał już ich umiejscowienie i przeznaczenie:

Filtrowanie
Filtrowanie danych omówiłem dość szczegółowo w tym wpisie. Wyjaśnię więc tylko, że faktury można filtrować po statusie, kliencie, produktach, dacie wystawienia i wartości. Na wyjaśnienie zasługuje filtrowanie produktów. Polega ono na tym, że użytkownik podaje początki nazw produktów, które powinna zawierać faktura, oddzielając je przecinkami. Oprócz podanych przez użytkownika, faktura może zawierać też inne produkty (czego nie widać na pokazanym screenie).

Status faktury
Możliwe statusy faktury to wystawiona i zapłacona. Pierwszy jest nadawany automatycznie po utworzeniu, drugi ustawia użytkownik (za pomocą przycisku zapłacona widocznego przy fakturze). Przy implementowaniu tej funkcjonalności, napotkałem problem natury językowej. Chciałem, aby w kodzie programu i bazie danych statusy miały nazwy angielskie (created, paid). Użytkownik powinien jednak widzieć je po polsku, dlatego napisałem dwie proste metody:

private static string TranslateInvoiceStatus(string status)
{
	if (status == Invoice.InvoiceStatus.Created.ToString())
		return "Wystawiona";
	else if (status == Invoice.InvoiceStatus.Paid.ToString())
		return "Zapłacona";
	else
		return "";
}

public static List<KeyValuePair<string, string>> GetInvoiceStatusList(this HtmlHelper helper) // ta metoda jest rozszerzeniem klasy HtmlHelper
{
	List<KeyValuePair<string, string>> result = new List<KeyValuePair<string, string>>();
	
	result.Add(new KeyValuePair<string, string>(TranslateInvoiceStatus(Invoice.InvoiceStatus.Created.ToString()), Invoice.InvoiceStatus.Created.ToString()));
	result.Add(new KeyValuePair<string, string>(TranslateInvoiceStatus(Invoice.InvoiceStatus.Paid.ToString()), Invoice.InvoiceStatus.Paid.ToString()));

	return result;
}

Przeznaczenie pierwszej metody jest uniwersalne, drugiej natomiast używam do wypełnienia rozwijanej listy statusów w filtrze faktur.

Termin płatności
Tutaj jedynie krótkie wyjaśnienie: jeśli faktura ma status inny niż zapłacona i jej termin płatności minął, jest on wyświetlany na czerwono – co widać na screenie.

PDF
Czas na najważniejszą funkcję całej aplikacji – generowanie pliku PDF. Po naciśnięciu przycisku PDF, widocznego przy każdej fakturze, użytownik wybiera, czy chce wygenerować oryginał dokumentu, czy jego kopię:

Po dokonaniu wyboru, wywoływana jest następująca metoda:

public ActionResult ToPdf(int id, PdfCreator.InvoiceType invoiceType)
{
	Invoice invoice = _invoiceRepository.GetById(id); // _invoiceRepository - prywatne repozytorium faktur
	string path = Server.MapPath("~/bin/lastInvoice.pdf"); // po stronie serwera, faktura zapisywana jest do pliku lastInvoice.pdf (być może nie jest to ostateczne rozwiązanie)
	string fileName = invoice.CreationDate.Value.ToShortDateString() + "_" + invoice.Customer.CompanyName + ".pdf"; // nazwa pliku otrzymywanego przez użytkownika jest postaci dataWystawienia_nazwaFirmyKlienckiej.pdf

	PdfCreator pdfCreator = new PdfCreator(invoice); // klasa PdfCreator tworzy dokument PDF na podstawie encji faktury z bazy danych
	pdfCreator.Create(invoiceType);
	pdfCreator.SaveDocument(path, false);

	return File(path, "PDF|*.pdf", fileName); // zwrócenie pliku PDF
}

Etykiety
Na koniec funkcjonalność niewidoczna na pokazanym na początku wpisu screenie – etykiety, pojawiające się po wskazaniu kursorem niektórych komórek tabeli zawierającej listę faktur:

  • po wskazaniu daty wystawienia faktury, wyświetlana jest etykieta zawierająca także datę sprzedaży,
  • do każdego produktu przypisana jest etykieta o treści: [ilość] [j.m.] ([wartość brutto] [waluta]), na przykład: 10 kg (100 PLN),
  • etykieta pola Do zapłaty zawiera również wartości pól Zapłacono i Pozostało do zapłaty.

Wygląda na to, że opisałem już wszystkie najważniejsze podstrony aplikacji. Prawdopodobnie zrezygnuję z tworzenia strony głównej, której rolę być może przejmie przedstawiona właśnie Historia faktur. Od teraz praca nad projektem będzie opierać się na testowaniu i szlifowaniu drobnych modułów programu, uzupełnię także informacje na stronie About. Jak widać, piętnaście tygodni to optymalna ilość czasu na stworzenie podobnego projektu i zdobycie sporej ilości wiedzy i doświadczenia.

Reklamy

Ostatnie okrążenie

Nadszedł wreszcie czas prezentacji finalnego modułu aplikacji. Jest nim model, kontrolery i widoki odpowiedzialne za wystawianie faktur VAT. Wszystkie te elementy są niejako rozszerzeniem ich odpowiedników obsługujących szablony faktur. Rozszerzenie polega głównie na dodaniu pól dat: wystawienia faktury, sprzedaży, terminu płatności; i tabeli zawierającej łączną wartość netto i brutto faktury, a także łączne wartości produktów o poszczególnych stawkach VAT. Skupię się tylko na tych zagadnieniach, jako że reszta aspektów wystawiania faktur jest analogiczna do tych dotyczących tworzenia ich szablonów. Po pełny kod odsyłam natomiast pod ten adres.

Daty na fakturze
Postawowym postanowieniem co do dat na stronie tworzenia faktury było wykorzystanie kontrolki typu datepicker. Przystępną jej implementację znalazłem w jQuery UI. Przyjemnym „ficzerem” tego pakietu jest możliwość zdefiniowania wyglądu kontrolek przed ściągnięciem paczki zawierającej arkusz css (z zestawem grafik) i bibliotekę jQuery. Nie obyło się jednak bez drobnej ingerencji w pobrane pliki – chciałem, aby nazwy dni i miesięcy wyświetlane były po polsku. Wymagało to jedynie podmiany odpowiednich literałów, przykładowo kod:

dayNamesMin:["Su","Mo","Tu","We","Th","Fr","Sa"]

zamieniłem na:

dayNamesMin:["Nd","Pn","Wt","Sr","Cz","Pt","Sb"]

W całej aplikacji używam formatu daty rok-miesiąc-dzień, dlatego kolejną (i ostanią) zmianą bazowego kodu kontrolki było ustawienie właściwości dateFormat na yy-mm-dd. Użycie datepicker wydaje się być, po krótkim obyciu z jQuery, oczywiste i intuicyjne:

$("#creationDate").datepicker(); // dodanie datepicker do pola daty wystawienia fakrury,
$("#saleDate").datepicker(); // ...daty sprzedaży...
$("#paymentDeadline").datepicker(); // ...i terminu płatności

Efekt końcowy jest w pełni zadowalający – wygodny w użyciu i miły oku:

Tabela wartości produktów według stawek VAT
Opisując ten element, najpierw pokażę, o co właściwie chodzi. Tabela produktów jest analogiczna do tej na stronie szablonów faktur (przdstawionej tutaj) – zawiera tylko więcej informacji o każdym produkcie. Pod nią (a właściwie jako jej kontynuacja) powinna znaleźć się tabela, której działanie zamierzam pokazać:

Obrazek w pełni wyjaśnia jej przeznaczenie. Za uaktualnianie tabeli odpowiada funkcja (javascript) updateGeneralValues, wywołująca za pomocą AJAX metodę (C#) GetGeneralValues, obliczającą łączne wartości produktów o kolejnych stawkach VAT i zwracającą następujący obiekt JSON:

// Celowo nie pokazuję całej treści metody - jest dość długa, a sposób jej działania nie jest właściwie istotny.
// Dociekliwych odsyłam do linka kończącego pierwszy akapit wpisu (opisywana metoda znajduje się w klasie InvoiceEditorController).

var data = new
{
	ToPay = toPay.ToString("F"), // toPay (decimal) - łączna wartość brutto wszystkich produktów
	VatRates = vatRates.ToArray(), // vatRates (List<string>) - stawki VAT występujące na fakturze
	NetValues = netValues, // netValues (string[]) - łączne wartości netto produktów o kolejnych stawkach VAT
	VatValues = vatValues, // vatValues (string[]) - łączne wartości tara produktów o kolejnych stawkach VAT
	GrossValues = grossValues // grossValues (string[]) - łączne wartości brutto produktów o kolejnych stawkach VAT
};

Bardziej szczegółowo zaprezentuję funkcję zajmującą się wyświetlaniem opisywanej tabeli: handleGeneralValuesUpdate. Wstawia ona wiersze tabeli w przygotowanie na to miejsce:

<tr> <!-- pierwszy wiersz tabeli jest zakodowany "na sztywno", znajduje się w nim również pole tekstowe służące do dodawania produktów -->
	<td>
		<input type="text" id="newProduct-name" style="width: 130px" /> <!-- rzeczone pole tekstowe -->
	</td>
	<td class="transparentRB"></td> <!-- transparentRB - prawa i dolna krawędź komórki jest niewidoczna -->
	<td class="transparentLB"></td> <!-- transparentLB - lewa i dolna krawędź komórki jest niewidoczna -->
	<td class="transparentLB"></td>
	<th class="separated" style="text-align: center">RAZEM</th> <!-- separated - górna krawędź komórki jest grubsza -->
	<td class="separated" align="right" id="generalNetValue">0,00</td> <!-- komórka przechowująca łączną wartość netto wszystkich produktów -->
	<td class="separated" align="center">X</td>
	<td class="separated" align="right" id="generalVatValue">0,00</td> <!-- komórka przechowująca łączną wartość tara wszystkich produktów -->
	<td class="separated" align="right" id="generalGrossValue">0,00</td> <!-- komórka przechowująca łączną wartość brutto wszystkich produktów -->
	<td class="transparentTRB"></td> <!-- transparentTRB - górna, prawa i dolna krawędź komórki jest niewidoczna -->
</tr>

<tr></tr> <!-- gwarancja, że dolne krawędzie poprzedniego wiersza pozostaną  niewidoczne po dodaniu kolejnych wierszy -->

<tr id="generalValues"></tr> <!-- w tym miejscu pojawią się kolejne wiersze -->

Jej treść wygląda zatem tak:

function handleGeneralValuesUpdate(data) { <!-- funkcja przyjmuje pokazany wcześniej obiekt JSON -->
	<!-- wypełnienie pierwszego wiersza tabeli (pierwsze elementy list przyjmowanego obiektu JSON zawierają łączne wartości dla wszystkich stawek VAT): -->
	$("#generalNetValue").text(data.NetValues[0]);
	$("#generalVatValue").text(data.VatValues[0]);
	$("#generalGrossValue").text(data.GrossValues[0]);

	$("tr[id*=vatRate-]").each(function () { $(this).remove() }); <!-- usunięcie pozostałych wierszy (patrz linia 12) -->

	var rowsCode = ""; <!-- kod nowych wierszy tabeli -->

	for (var i = 1; i != data.VatRates.length; i++) { <!-- nowych wierszy jest tyle, ile stawek VAT występuje w tabeli produktów -->
		rowsCode += '<tr id="vatRate-' + data.VatRates[i] + '">' + <!-- nowy wiersz dostaje odpowiedni identyfikator -->
			'<td class="transparentLTB"></td>' + <!-- transparentLTB - lewa, górna i dolna krawędź komórki jest niewidoczna -->
			'<td class="transparentLTB"></td>' +
			'<td class="transparentLTB"></td>' +
			'<td class="transparentLTB"></td>';

		if (i == 1) rowsCode += '<th style="text-align: center">W tym</th>'; <!-- pierwszy z nowych wierszy będzie zawierał napis "W tym" -->
		else rowsCode += '<th></th>';
		
		rowsCode += '<td align="right">' + data.NetValues[i] + '</td>' + <!-- komórka zawierająca łączną wartość netto produktów -->
			'<td align="right">' + data.VatRates[i] + '</td>' + <!-- komórka zawierająca kolejną stawkę VAT -->
			'<td align="right">' + data.VatValues[i] + '</td>' + <!-- komórka zawierająca łączną wartość tara produktów -->
			'<td align="right">' + data.GrossValues[i] + '</td>' + <!-- komórka zawierająca łączną wartość brutto produktów -->
			'<td class="transparentTRB"></td>' + <!-- transparentTRB - górna, prawa i dolna krawędź komórki jest niewidoczna -->
			'</tr>';
	}

	rowsCode += '<tr id="generalValues"></tr>'; <!-- ten wiersz zostanie wykorzystany przy następnej aktualizacji tabeli -->

	$("#generalValues").replaceWith(rowsCode); <!-- wstawienie rowsCode w przygotowane miejsce -->

	<!-- uaktualnienie innych elementów strony, tutaj nieistotne -->
}

Ostatni komentarz w powyższym kodzie sygnalizuje, że nie zaprezentowałem pełnego ciała funkcji. Oprócz opisywanej tabeli, aktualizuje ona także pola Razem do zapłaty, Słownie do zapłaty i Pozostało do zapłaty – nie jest to jednak nic skomplikowanego.

Bez wątpienia, strona tworzenia faktur wymagała najwięcej pracy. Na szczęście zawiera wiele elementów analogicznych do tych ze strony tworzenia szablonów faktur (np. tabela produktów), która z kolei zawiera elementy analogiczne do tych ze strony tworzenia klientów, czy produktów (np. listy rozwijane). Przy tworzeniu żadnej ze stron nie rzuciłem się więc od razu na głęboką wodę – mogłem stopniowo poznawać nowe kontrolki i mechanizmy. Tym sposobem, bez większych problemów ukończyłem najtrudniejszą część projektu. Teraz pozostało tylko wykorzystać zdobytą wiedzę i stworzyć brakujące strony: historię faktur i stronę główną aplikacji. Postaram się pokonywanie tej ostatniej prostej opisywać z większą częstotliwością… W końcu do zakończenia konkursu już tylko cztery tygodnie!

Starcia runda druga: produkty na szablonie faktury

Ostatnio opisałem rozwiązanie pierwszej części mojego problemu. Po tygodniowej przerwie, wypełnionej korzystaniem z ostatnich dni wakacji, wracam na ring i ostatecznie nokautuję przeciwnika na trzech płaszczyznach.

1. HTML
Pierwszą z nich jest struktura tebeli produktów (szczegóły w punkcie drugim listy umieszczonej w akapicie O co chodziło? poprzedniego wpisu). Składają się na nią następujące wiersze:
– nagłówek tabeli:

<table>
	<tr>
		<th>Nazwa</th>
		<th>PKWiU</th>
		<th title="Jednostka miary">J. miary</th>
		<th>Cena netto</th>
		<th>Stawka VAT</th>
		<th style="min-width: 52px"></th>
	</tr>

– produkty juz zawarte w szablonie (istotne przy edycji już istniejącego szablonu):

<% foreach (var item in Model.Products) { %> <!-- Model - szablon faktury, Products - produkty zawarte w szablonie -->
<tr id="product-<%: item.Id %>"> <!-- każdy wiersz ma odpowiedni identyfikator -->
	<td><%: item.Name %></td>
	<td align="center"><%: item.Pkwiu %></td>
	<td><%: item.MeasureUnit %></td>
	<td align="right"><%: item.NetPrice.ToString("N") %></td>
	<td align="right"><%: item.VatRate %></td>
	<td>
		<input type="button" value="Usuń" onclick="removeProduct(<%: item.Id %>)" /> <!-- przycisk usuwania produktu, funkcję removeProduct pokażę w akapicie dotyczącym jQuery -->
	</td>
</tr>
<% } %>

– pusty wiersz, w miejsce którego wstawiane będą dodawane przez użytkownika produkty:

<tr id="addedProduct"></tr>

– wiersz umożliwiający dodanie produktu do szablonu:

	<tr id="newProduct">
		<td>
			<input type="text" id="newProduct-name" style="width: 130px" /> <!-- pole tekstowe umożliwiające dodanie produktu -->
		</td>
		<td class="transparentRB"></td> <!-- komórka o niewiczonej prawej i dolnej krawędzi -->
		<td class="transparentLRB"></td> <!-- komórka o niewiczonej lewej, prawej i dolnej krawędzi -->
		<td class="transparentLRB"></td>
		<td class="transparentLRB"></td>
		<td class="transparentLRB"></td>
	</tr>
</table>

Całość, dla szablonu z już dodanymi produktami, prezentuje się tak:

2. C#
Skoro uzyskałem wygląd tabeli, mogę zabrać się za napisanie kilku metod C#, z których korzystać będą (poprzez AJAX) funckje jQuery. Interakcja z użytkownikiem wygląda tak:
1. Użytkownik wpisuje początek nazwy produktu, który zamierza dodać do szablonu,
2. Dzięki Autocomplete i metodzie FilterProducts, wyświetlana jest lista podpowiedzi,
3. Użytkownik wybiera produkt z listy,
4. Wywoływana jest metoda AddProduct,
5. Użytkownik, jeśli chce, usuwa produkt (przycisk Usuń),
6. Po wciśnięciu przycisku Usuń wywoływana jest metoda RemoveProduct.

Pominąłem tutaj rolę jQuery – opiszę ją w kolejnym paragrafie. Wspomnę na razie tylko o wielce wygodnym pluginie, jakim jest Autocomplete (używam jego zmodyfikowanej wersji). Odpowiada on dokładnie za to, czego potrzebuję: wyświetla podpowiedzi pod polem tekstowym. W tym miejscu jedyną istotną informacją dotyczącą Autocomplete jest to, że, komunikując się z kontrolerem, przekazuje mu treść pola tekstowego (w tym przypadku początek nazwy produktu) w parametrze o nazwie „q”, a przyjmuje ciąg znaków zawierający kolejne pozycje listy podpowiedzi (oddzielone znakiem ‚\n’). Metoda filtrowania produktów, wymieniona w punkcie drugim przedstawionej wyżej listy, prezentuje się więc tak:

public ActionResult FilterProducts(string q)
{
	List<RegisteredProduct> products = _productRepository.GetByName(q);
	string[] contents = new string[products.Count];

	for (int i = 0; i != products.Count; i++)
		contents[i] = products[i].Name + "|" + products[i].Id.ToString(); // znak '|' oddziela treść pola listy od dodatkowych, przypisanych do niego, wartości

	return Content(string.Join("\n", contents));
}

Listę produktów zawartych w szablonie, a właściwie ich identyfikatorów, przechowuję w danych sesji (Session[„ProductIds”]). Dodawanie i usuwanie produktów opiera się zatem głównie na operacjach na tej liście. Metoda dodawania zwraca ponadto dane dodawanego produktu:

public ActionResult AddProduct(int id)
{
	List<int> productIds = Session["ProductIds"] as List<int>; // pobranie wspomnianej listy identyfikatorów

	if (id == 0 || productIds.Contains(id))
		return Json(null); // wielokrotne dodawanie tego samego produktu nie jest dozwolone

	productIds.Add(id);

	RegisteredProduct product = _productRepository.GetById(id); // _productRepository - prywatne repozytorium produktów

	var data = new // dane dodawanego produktu
	{
		Id = product.Id,
		Name = product.Name,
		Pkwiu = product.Pkwiu,
		MeasureUnit = product.MeasureUnit,
		NetPrice = product.NetPrice.Value.ToString("N"),
		VatRate = product.VatRate
	};

	return Json(data);
}

[HttpPost]
public ActionResult RemoveProduct(int id)
{
	List<int> productIds = Session["ProductIds"] as List<int>;
	productIds.Remove(id);

	return null;
}

Teraz pozostaje tylko spiąć sztywny kod HTML i nie-tak-łatwo-dostępne-z-poziomu-html metody C# za pomocą:

3. jQuery
Na tej płaszczyźnie nie pojawi się chyba nic, czego nie opisałem w poprzednim wpisie (no dobrze, pojawi się jedno słowo: autocomplete). Aby jednak zwieńczyć dzieło, pokażę poszczególne funkcje. Na początek kilka linijek ułatwiających dostęp do metod kontrolera:

var url_FilterProducts = '<%: Url.Action("FilterProducts", "PatternEditor") %>';
var url_AddProduct = '<%: Url.Action("AddProduct", "PatternEditor") %>';
var url_RemoveProduct = '<%: Url.Action("RemoveProduct", "PatternEditor") %>';

Wspomniany już kilkukrotnie plugin Autocomplete zaimplementowałem w ten sposób:

$("#newProduct-name") // selekcja pola tekstowego nazwy dodawanego produktu
	.autocomplete(
		url_FilterProducts, // ścieżka do metody zwracającej listę podpowiedzi
		{
			delay: 50, // lista podpowiedzi pojawi się 50ms po wpisaniu znaku w polu tekstowym
			onItemSelect: function (li) { // funkcja wywoływana po wybraniu podpowiedzi, pobiera pole listy (li)
				$.post(url_AddProduct, { "id": li.extra[0] }, handleProductAddition); // użycie AJAX do wywołania metody AddProduct kontrolera, następnie wywołanie funkcji handleProductAddition
				}
		}
	);

Występująca w powyższym kodzie funkcja handleProductAddition odpowiada za dodanie produktu do tabeli – wstawia nowy wiersz w przygotowane na to miejsce (patrz addedProduct w paragrafie HTML). Jej treść wygląda tak:

function handleProductAddition(data) { // funkcja pobiera dane produktu
	if (data) { // patrz szósta linia metody AddProduct (paragraf "C#")
		$("#addedProduct").replaceWith( // wstawienie danych produktu w miejsce pustego wiersza
		'<tr id="product-' + data.Id + '">' + // wiersz otrzymuje swój identyfikator
			'<td>' + data.Name + '</td>' +
			'<td align="center">' + data.Pkwiu + '</td>' +
			'<td>' + data.MeasureUnit + '</td>' +
			'<td align="right">' + data.NetPrice + '</td>' +
			'<td align="right">' + data.VatRate + '</td>' +
			'<td>' +
				'<input type="button" value="Usuń" onclick="removeProduct(' + data.Id + ')"/>' +
				// kliknięcie przycisku wywołuje funkcję removeProduct, jako parametr podając identyfikator produktu, a tym samym wiersza tabeli
			'</td>' +
		'</tr>' +
		'<tr id="addedProduct"></tr>' // pusty wiersz zostaje, przyda się do dodania następnego produktu
		);
	}
	else { // wielokrotne dodawanie tego samego produktu nie jest dozwolone
		showProductError("Ten produkt został już dodany"); // funkcja showProductError wyświetla treść błędu pod tabelą produktów
	}

	$("#newProduct-name").val(""); // wyczyszczenie treści pola tekstowego
	$("#newProduct-name").focus(); // focus pozostaje na polu tekstowym
}

Usuwanie produktu odbywa się o wiele prościej. Po kliknięciu przez użytkownika przycisku Usuń, wywoływana jest następująca funkcja:

function removeProduct(id) { // funkcja pobiera identyfikator usuwanego produktu
	$.post(url_RemoveProduct, { "id": id }); // wywołanie metody RemoveProduct kontrolera (identyfikator produktu przekazywany jest przez parametr o nazwie "id")
	$("tr[id=product-" + id + "]").remove(); // selekcja i usunięcie wiersza zawierającego dane produktu
}

KO!
Uff, to wszystko. Działanie kodu w praktyce można sprawdzić po ściągnięciu stąd. Pojedynek nie był może tak widowiskowy jak walki MMA, jednak liczy się to, że przeciwnik leży na deskach. Mogę teraz przejść nad nim, by zmierzyć się z finalnym bossem – wystawianiem faktur.

Wieczorek zapoznawczy z jQuery, czyli get the $(„# in!

Po kilkudniowej nieobecności wśród cywilizacji, wracam do pracy. Dziś przedstawię rozwiązanie przedstawionego ostatnio problemu.

O co chodziło?
Chodziło o szablony faktur. Dodaję tę funkcjonalność po to, aby użytkownik, jeśli zdarza mu się wystawiać co miesiąc identyczne faktury, nie musiał tworzyć każdej z nich od zera. Szablon przechowuje następujące informacje:
– odbiorca faktury (klient),
– produkty, na które zostanie wystawiona faktura,
– sposób płatnośći (przelew, gotówka itp.),
– waluta płatnośći,
– ewentualne uwagi.
Tworząc widok edycji szablonów, napotkałem na przeszkodę. Chciałem w nim umieścić dwa elementy wymagające wymiany danych między widokiem i jego kontrolerem, bez konieczności odświeżania całej strony:

  1. rozwijana lista klientów – po wyborze klienta, jego dane są wyświetlane w tabeli,
  2. lista produktów – tabela zawierająca informacje o wybranych produktach (z możliwością ich usuwania) i umożliwiająca dodanie produktów do listy (pole tekstowe z zaimplementowanym autouzupełnianiem).

Moja wiedza nie była wystarczająca, by podołać takiemu wyzwaniu, dlatego musiałem ją poszerzyć.

Write less, do more!
Nagłówek tego akapitu to motto biblioteki JavaScript, z którą postanowiłem się zapoznać: jQuery (podlinkowana strona jest godna gorącego polecenia, zawiera absolutnie wszystko, czego potrzeba do nauki). Nie miałem nigdy do czynienia z JavaScript, dlatego początkowo trudno było mi rozczytywać przykłady z dokumentacji (zwłaszcza, że niejedna linijka kodu w nich zawartego zaczyna się od zlepka $(„#, widniejącego w tytule wpisu). Opanowanie podstaw nie okazało się jednak trudne, po kilku godzinach przeszedłem więc do zagadnienia, które interesowało mnie najbardziej, czyli sposobu wykorzystania w jQuery AJAX. I tu nie napotkałem większych trudności, dlatego zabrałem się za ostatni (po znalezieniu rozwiązania i zdobycia odpowiedniej wiedzy) etap walki z moim problemem: kodowanie.

Ostatnie starcie: runda pierwsza
Metody zwracające obiekty JSON postanowiłem oddzielić od tych (umieszczonych w kontrolerze PatternsController) zwracających konkretne widoki, dlatego stworzyłem nowy kontroler: PatternEditorController. Następnie zacząłem komponować opisany wyżej widok edycji szablonów faktur. Na pierwszy ogień poszła realizacja pierwszej z wypunktowanych funkcjonalności: lista klientów i tabela danych wybranego klienta. Garść linii kodu HTML zdefiniowało wygląd tworzonego elementu:

(pochyloną czcionką wpisałem identyfikatory odpowiednich komórek tabeli)
Kilkanaście linii JavaScript, przy pomocy jQuery, narzuciło zachowanie listy customer-name:

var url_GetCustomer = '<%: Url.Action("GetCustomer", "PatternEditor") %>'; // URL do odpowiedniej akcji zwracającej obiekt JSON

$("#customer-name") // selekcja elementu o identyfikatorze customer-name
	.change( // zdarzenie zgłaszane po wyborze którejś z pozycji listy
		function () { // funckja anonimowa wywoływana po zgłoszeniu zdarzenia
			$.post(url_GetCustomer, { "id": this.value }, handleCustomerUpdate); // wykorzystanie AJAX (http://api.jquery.com/jQuery.get/)
		}
	);

function handleCustomerUpdate(customer) {
	// wypełnienie zawartości odpowiednich komórek tabeli
	$("#customer-street").text(customer.Street);
	$("#customer-city").text(customer.City);
	$("#customer-postalCode").text(customer.PostalCode);
	$("#customer-nip").text(customer.Nip);
}

Natomiast pojedyncza metoda C# przejęła odpowiedzialność za pobranie danych klienta i przekazanie ich do widoku:

[HttpPost]
public ActionResult GetCustomer(int id)
{
	RegisteredCustomer customer = _customerRepository.GetById(id); // _customerRepository - prywatne repozytorium klientów

	var data = new
	{
		CustomerName = customer.CustomerName,
		CompanyName = customer.CompanyName,
		Street = customer.Street,
		City = customer.City,
		PostalCode = customer.PostalCode,
		Nip = customer.Nip
	};

	return Json(data);
}

Efekt jest widoczny na załączonym obrazku:

Póki co, ja 1 : 0 MVC 2.

Jak widać, osiągnięcie żądanego efektu okazało się nie być wcale trudne i skomplikowane. Wystarczyło kilka(naście?) godzin, abym mógł dopisać jQuery do dość długiej już listy technologii (choć może w tym przypadku to zbyt duże słowo) poznanych podczas pracy nad projektem. W kolejnym wpisie zrelacjonuję drugą, nieco bardziej skomplikowaną, rundę tego starcia – realizację tabeli produktów. Zapraszam!


EDIT: Ostatnie wakacyjne wyjazdy pochłonęły sporo mojego wolnego czasu, stąd w tym i poprzednim tygodniu tylko po jednym wpisie. Zgodnie z regulaminem konkursu (i jego FAQ), te dwa tygodnie traktuję jako jeden tydzień pracy i jeden „urlopu”. Wykorzystałem zatem drugi z pięciu przysługujących tygodni wolnego.

Przestój w kodowaniu i prezentacja wyglądu aplikacji

Po stworzeniu stron produktów, klientów i profilu użytkownika, zamierzałem rzutem na taśmę dodać strony faktur i ich szablonów. Okazało się to jednak trudniejsze niż myślałem.

Przedstawienie problemu
Jako pierwsza, miała powstać strona szablonów faktur. Scenariusze przeglądania listy istniejących szablonów, usuwania ich, a także przeglądania ich szczegółów, są analogiczne do ich odpowiedników na stronach produktów i klientów, dlatego łatwe to zrealizowania. Problematyczne okazało się jednak dodawanie i edytowanie szablonów – realizowane w jednym widoku (partial view): PatternEditor.ascx. Chciałem w nim umieścić następujące elementy:

  • rozwijana lista klientów – po dokonaniu wyboru, w przygotowanej tabelce powinny zostać wyświetlone dane wybranego klienta,
  • lista produktów z możliwością dodawania i usuwania pozycji – zrealizowana w formie tabeli, której ostatni wiersz zawiera pole tekstowe (pod którym, po wpisaniu przez użytkownika początku nazwy produktu, wyświetlona zostanie lista podpowiedzi) i przycisk umożliwiający dodanie wybranego produktu
  • rozwijana lista dostępnych form płatności,
  • pole tekstowe przeznaczone do wpisania waluty płatności,
  • pole tekstowe przeznaczone do wpisania ewentualnych uwag do faktury.

O ile trzy ostatnie punkty to (od dwóch tygodni) nic nowego, o tyle dwa pierwsze wymagają głębszego zanurzenia się w ASP.NET MVC – poznania sposobu na wymianę danych między widokiem a kontrolerem bez konieczności odświeżania strony. Pierwsze, co mi przyszło na myśl, to oczywiście AJAX. Tutorial, z którego korzystam, wspomina co prawda o nim i pokazuje jeden przykład wykorzystania, ale to za mało.

Co się odwlecze…
Przez ostatnie kilka dni byłem odcięty od sieci – więc i od świata. Mogłem po omacku szukać rozwiązania, lub zająć się wykorzystaniem już posiadanej wiedzy i oprogramować przeglądanie i usuwanie szablonów – wybrałem to drugie. Zorientowałem się jednocześnie, że jeszcze w żadnym miejscu na blogu nie zaprezentowałem interfejsu mojej aplikacji. Co prawda, spora część widoków i prawie cały arkusz stylów to kod wygenerowany automatycznie, jednak z „dziennikarskiego obowiązku” pokażę kilka zrzutów ekranu:
[dane klientów i produktów są zupełnie nieistotne]
lista produktów
tworzenie nowego klieta
lista szablonów faktur
szczegóły szablonu faktury

Zanosi się na to, że w tym tygodniu nie zdążę już nic merytorycznego napisać. W przyszłym, mam nadzieję, uda mi się opisać rozwiązanie problemu stworzenia opisanego wcześniej widoku PatternEditor. Po krótkim przeglądzie dostępnych technologii, mam już chyba kandydata na miarę moich potrzeb: jQuery. Ale o tym w następnym odcinku. Zapraszam!

Filtrowanie danych

Po wprowadzeniu InvoiceInvoker w stadium pre-pre-beta, zająłem się dodawaniem kolejnych modeli, widoków i kontrolerów. Jak na razie projekt uzupełniłem o stronę klientów (bazującą na tabeli RegisteredCustomers bazy danych i analogiczną to strony produktów, więc niestanowiącą wyzwania). Strony faktur i ich szablonów wymagać będą przemyślenia, dlatego zostawiam je na deser. Dziś natomiast przybliżę sposób wyłuskiwania interesujących użytkownika pozycji z nieprzebranego mrowia produktów i niezliczonych szeregów klientów – w skrócie: filtrowania danych.

Filtrowanie udostępniane przez repozytoria
Opisując implementację wzorca repozytorium w warstwie dostępu do danych, zwróciłem uwagę na metodę GetByExpression, pozwalającej na pobranie obiektów spełniających dowolne warunki.:
(tutaj dla repozytorium klientów)

public List<RegisteredCustomer> GetByExpression(Func<RegisteredCustomer, bool> expression)
{
	using (MainDataContext dataContext = new MainDataContext(_connectionString))
	{
		var mainQuery = from customer in dataContext.RegisteredCustomers
						where customer.RegisteredSellerId == _registeredSellerId // metody grupowej selekcji zwracają encje przypisane konkretnemu sprzedawcy
						orderby customer.CompanyName ascending
						select customer;
		return mainQuery.Where(expression).ToList();
	}
}

Postanowiłem udostępnić użytkownikowi filtrowanie klientów po imieniu, nazwisku i nazwie firmy, a produktów po nazwie i cenie netto (przez zdefiniowanie ceny minimalnej i maksymalnej).

Konstrukcja filtrów
Dla filtru produktów stworzyłem klasę przechowującą filtrowane pola i konstruującą z nich wyrażenie lambda, przekazywane wspomnianej przed chwilą metodzie repozytorium:

public class ProductsFilter
{
	[DisplayName("Nazwa")]
	public string Name { get; set; }

	[DisplayName("Min. cena netto")]
	[RegularExpression("^[0-9]+(,[0-9])?[0-9]*$")]
	public string MinPrice { get; set; }

	[DisplayName("Maks. cena netto")]
	[RegularExpression("^[0-9]+(,[0-9])?[0-9]*$")]
	public string MaxPrice { get; set; }

	public Func<RegisteredProduct, bool> GetExpression()
	{
		string name = Name ?? "";
		decimal minPrice;
		decimal maxPrice;

		decimal.TryParse(MinPrice, out minPrice);

		if (decimal.TryParse(MaxPrice, out maxPrice) == false)
			maxPrice = decimal.MaxValue;

		return product => product.Name.ToLower().StartsWith(name.ToLower()) && product.NetPrice >= minPrice && product.NetPrice <= maxPrice;
	}
}

W przypadku filtrowania po nazwie, zgodność początku nazwy produktu z podanym przez użytownika ciągiem znaków (wielkość liter nie ma znaczenia) wystarczy, aby produkt został wyświetlony.
Filtr klientów posiada tylko jedno (dlatego nie utworzyłem jego klasy) kryterium: NameFilter. Akceptuje klienta, jeśli podany przez użytkownika ciąg znaków jest początkiem jego imienia (lub któregoś z imion), nazwiska albo nazwy firmy. Wielkość liter i tutaj nie ma znaczenia. Wyrażenie lambda tego filtru wygląda więc tak:

Func<RegisteredCustomer, bool> expression = customer =>
	customer.CustomerName.ToLower().Split(' ').Any(x => x.StartsWith(NameFilter.ToLower())) ||
	customer.CompanyName.ToLower().StartsWith(NameFilter.ToLower());

(Zaznaczona linia dzieli nazwę (imię i nazwisko) klienta na słowa, w których szuka tych akceptowanych przez filtr.)

Implementacja w kontrolerach
Filtrowanie i wyświetlanie pozycji udostępniają akcje (i widoki) Index stron klientów i produktów. Korzystają one z odpowiednich modeli:
(dla strony klientów)

public class CustomersIndexViewModel
{
	[DisplayName("Imię i nazwisko lub nazwa firmy")]
	public string NameFilter { get; set; } // wspomniane pole filtru klientów
	public List<RegisteredCustomer> Customers { get; set; } // lista klientów
}

(dla strony produktów)

public class ProductsIndexViewModel
{
	public ProductsFilter Filter { get; set; } // filtr produktów
	public List<RegisteredProduct> Products { get; set; } // lista produktów
}

Akcje wyglądają więc tak:
(dla strony klientów)

public ActionResult Index(CustomersIndexViewModel viewModel)
{
	string nameFilter = viewModel.NameFilter ?? "";

	Func<RegisteredCustomer, bool> expression = customer => // pokazane wcześniej zapytanie
		customer.CustomerName.ToLower().Split(' ').Any(x => x.StartsWith(nameFilter.ToLower())) ||
		customer.CompanyName.ToLower().StartsWith(nameFilter.ToLower());

	viewModel.Customers = _repository.GetByExpression(expression); // _repository - prywatne repozytorium klientów

	return View(viewModel);
}

(dla strony produktów)

public ActionResult Index(ProductsIndexViewModel viewModel)
{
	if (viewModel.Filter == null)
		viewModel.Filter = new ProductsFilter();

	viewModel.Products = _repository.GetByExpression(viewModel.Filter.GetExpression()); // _repository - prywatne repozytorim produktów

	return View(viewModel);
}

Implementacja w widokach
Pozostało jeszcze tylko w odpowiednich widokach wyświetlić po kilka pól tekstowych i po dwa przyciski: aktywujące bądź czyszczące filtry. W widoku klientów wygląda to tak:

<% using (Html.BeginForm()) { %>
<fieldset>
    <legend>Filtr</legend>
    <%: Html.LabelFor(model => model.NameFilter) %>: <!-- etykieta pola filtru -->
    <%: Html.TextBoxFor(model => model.NameFilter) %> <!--  pole tekstowe pola filtru -->
    <input type="submit" value="Filtruj" /> | <%: Html.ActionLink("Wyczyść", "Index") %>
</fieldset>
<% } %>

W widoku produktów, tak:

<% using (Html.BeginForm()) { %>
<fieldset>
    <legend>Filtr</legend>
    <%: Html.LabelFor(model => model.Filter.Name) %>: <!-- etykieta pola nazwy -->
    <%: Html.TextBoxFor(model => model.Filter.Name) %> <!-- pole tekstowe nazwy -->
    <%: Html.LabelFor(model => model.Filter.MinPrice) %>: <!-- etykieta pola ceny minimalnej -->
    <%: Html.TextBoxFor(model => model.Filter.MinPrice) %> <!-- pole tekstowe ceny minimalnej -->
    <%: Html.LabelFor(model => model.Filter.MaxPrice) %>: <!-- etykieta pola ceny maksymalnej -->
    <%: Html.TextBoxFor(model => model.Filter.MaxPrice) %> <!-- pole tekstowe ceny maksymalnej -->
    <input type="submit" value="Filtruj" /> | <%: Html.ActionLink("Wyczyść", "Index") %>
</fieldset>
<% } %>

Tym sposobem dałem użytkownikowi możliwość usunięcia sprzed swych szanownych oczu nieinteresujących go pozycji. Za kilka dni dam mu również możliwość definiowania szablonów faktur. Zapraszam!

Exodus

Na początek zapowiedzniane w poprzednim wpisie kilka linijek o rozwiązaniu problemu z automatyczną walidacją danych – a właściwie jej brakiem. Rozwiązaniem okazała się być zmiana nagłówka metody kontrolera z takiego:
(generowanego automatycznie, tutaj dla scenariusza tworzenia nowego produktu)

[HttpPost]
public ActionResult Create(FormCollection collection)

na taki:

[HttpPost]
public ActionResult Create(ProductsViewModel viewModel)

gdzie ProductsViewModel jest klasą, na której bazuje silnie typowany widok dodawania produktu:

public class ProductsViewModel
{
	public ProductModel Product { get; set; } // patrz typ RegisteredProductModel przedstawiony w poprzednim wpisie
	public List<string> VatRates { get; set; } // stawki VAT możliwe do przypisania produktowi
}

Tak jak się spodziewałem – banał. Coż, tak bywa z rzeczami na tyle prostymi, że nie są jasno omówione w tutorialach. Przejdźmy jednak do tematu przewodniego dzisiejszego wpisu:

Pierwsza uruchamialna wersja programu
Po pięciu tygodniach pracy mogę wreszcie sprawdzić działanie kodu w praktyce – nie tylko, jak dotychczas, poprzez testy jednostkowe. Przez kilka dni nauki maglowałem tabelę RegisteredProducts bazy danych. Stworzyłem jej model, kontoler i widoki, realizujące przeglądanie, dodawanie, edytowanie i usuwanie produktów. Następnie wziąłem się za mechanizm rejestracji i logowania użytkowników. Jest on implementowany automatycznie, więc pozostało mi tylko zapoznać się z jego działaniem.
Dopiero teraz mogłem przemyśleć sposób przypisywania encji bazy danych konkretnym użytkownikom. Jako że nazwa użytkownika jest unikalna, zdecydowałem się na dodanie do tabeli RegisteredSellers kolumny UserName (co widać na załączonym w poprzednim akapicie schemacie) i używania jej jako łącznika między użytkownikiem, a encją. Po zarejestrowaniu nowego użytkownika, do bazy danych dodawany jest nowy obiekt RegisteredSeller – z jego nazwą umieszczoną w polu UserName.
Zarejestrowany użytkownik jest przenoszony na stronę swojego profilu, gdzie może podać dane takie jak imię, nazwisko, czy nazwa i adres firmy. Ma też do dyspozycji wspomnianą wcześniej stronę produktów, z możliwością filtrowania po nazwie i cenie. Niezalogowani użytkownicy mogą zobaczyć jedynie stronę O programie. Na razie to wszystko, aplikację w obecnej formie można pobrać tutaj.
Należałoby jeszcze wspomnieć o sposobie przechowywania (podczas sesji) informacji o zalogowanym użytkowniku. W dowolnym (oprócz konstruktorów) miejscu kontrolerów mam dostęp do jego nazwy:

string userName = User.Identity.Name;

Bardziej przydałby mi się jednak identyfikator encji RegisteredSeller powiązanej z użytkownikiem. Do repozytorium sprzedawców (RegisteredSellerRepository) dodałem więc taką metodę:

public int GetIdByUserName(string userName)
{
	using (MainDataContext dataContext = new MainDataContext(_connectionString))
	{
		return dataContext.RegisteredSellers.SingleOrDefault(seller => seller.UserName == userName).Id;
	}
}

Kontroler produktów uzupełniłem natomiast o metodę wywoływaną przed wykonaniem każdej akcji:

protected override void OnActionExecuting(ActionExecutingContext filterContext)
{
	if (Session["SellerId"] == null)
		Session["SellerId"] = new RegisteredSellerRepository().GetIdByUserName(User.Identity.Name);

	_sellerId = (int)Session["SellerId"]; // _sellerId - prywatne pole
	_repository = new RegisteredProductRepository(_sellerId); // _repository - prywatne pole

	base.OnActionExecuting(filterContext);
}

Zaznaczony fragment będzie powtarzał się w każdym kontrolerze, a wykorzystany zostanie podczas pojedynczej sesji tylko raz, przy wykonaniu pierwszej w programie akcji, dlatego takie rozwiązanie niezbyt mi się podoba. Na razie nie znalazłem jednak lepszego – może Czytelnicy takie znają?