view src/main.cpp @ 217:58af72da7f60

Update copyrights.
author Matti Hamalainen <ccr@tnsp.org>
date Tue, 02 Jan 2018 01:47:38 +0200
parents 8b9d55fb8988
children c3f47d489097
line wrap: on
line source

//
// Syntilista - debt list/management database program
// Programmed and designed by Matti Hämäläinen <ccr@tnsp.org>
// (C) Copyright 2017-2018 Tecnic Software productions (TNSP)
//
// Distributed under 3-clause BSD style license, refer to
// included file "COPYING" for exact terms.
//
#include <QApplication>
#include <QMessageBox>
#include <QSettings>
#include <QStandardPaths>
#include "main.h"
#include "ui_mainwindow.h"
#include "ui_editperson.h"
#include "ui_aboutwindow.h"


//
// Application settings struct
//
struct
{
    QPoint uiPos;
    QSize  uiSize;
    double uiScale;              // Global UI scale factor

    QString dataPath;            // Application data path/directory

    // Backup related settings
    int     dbBackupMode;
    QString dbBackupURL;
    QString dbBackupSecret;
    QDateTime dbLastBackup;
} settings;



//
// Convert QString to a double value, replacing comma
//
double slMoneyStrToValue(const QString &str)
{
    QString str2 = str;
    return str2.replace(",", ".").toDouble();
}


//
// Convert double value to formatted QString
//
QString slMoneyValueToStr(double val)
{
    return QStringLiteral("%1").arg(val, 1, 'f', 2);
}


QString slMoneyValueToStrSign(double val)
{
    return QStringLiteral("%1%2").
        arg(val > 0 ? "+" : "").
        arg(val, 1, 'f', 2);
}


//
// Trim and cleanup given QString (removing double whitespace etc.)
//
QString slCleanupStr(const QString &str)
{
    return str.simplified().trimmed();
}


//
// Manipulate given QDateTime value to get desired
// correct timestamp.
//
const QDateTime slDateTimeToLocal(const QDateTime &val)
{
    QDateTime tmp = val;
    tmp.setOffsetFromUtc(0);
    return tmp.toLocalTime();
}


//
// Return a string representation of given QDateTime
// converted to local time.
//
const QString slDateTimeToStr(const QDateTime &val)
{
    return slDateTimeToLocal(val).toString(QStringLiteral("yyyy-MM-dd hh:mm"));
}


//
// Error logging
//
void slLog(const QString &mtype, const QString &msg)
{
    QString filename = settings.dataPath + QDir::separator() + APP_LOG_FILE;
    QFile fh(filename);
    if (fh.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text))
    {
        QTextStream out(&fh);
        out <<
            slDateTimeToLocal(QDateTime::currentDateTimeUtc()).
            toString(QStringLiteral("yyyy-MM-dd hh:mm:ss"))
            << " [" << mtype << "]: " << msg << "\n";
        fh.close();
    }
}


//
// Display an error dialog with given title and message
//
int slErrorMsg(const QString &title, const QString &msg)
{
    QMessageBox dlg;

    slLog("ERROR", msg);

    dlg.setText(title);
    dlg.setInformativeText(msg);
    dlg.setTextFormat(Qt::RichText);
    dlg.setIcon(QMessageBox::Critical);
    dlg.setStandardButtons(QMessageBox::Ok);
    dlg.setDefaultButton(QMessageBox::Ok);

    return dlg.exec();
}


//
// Check if an SQL error has occured (for given QSqlError) and
// report it to stdout if so. Return "false" if error has occured,
// true otherwise.
//
bool slCheckAndReportSQLError(const QString where, const QSqlError &err, bool report)
{
    if (err.isValid())
    {
        // If an error has occured, log it
        slLog("ERROR",
            QStringLiteral("SQL %1: %2").
            arg(where).arg(err.text()));
        return false;
    }
    else
    {
        // If no error, but event reporting requested, log it
        if (report)
        {
            slLog("NOTE",
                QStringLiteral("SQL OK %1").arg(where));
        }
        return true;
    }
}


void SLPersonInfo::dump()
{
    printf(
        "SLPersonInfo() #%lld '%s %s' (added=%s, updated=%s, balance %1.2f)\n#%s#\n",
        id,
        firstName.toUtf8().constData(),
        lastName.toUtf8().constData(),
        slDateTimeToStr(added).toUtf8().constData(),
        slDateTimeToStr(updated).toUtf8().constData(),
        balance,
        extraInfo.toUtf8().constData());
}


//
// Get SLPersonInfo record from SQL query object
//
void slGetPersonInfoRec(QSqlQuery &query, SLPersonInfo &info)
{
    info.id         = query.value(0).toInt();
    info.firstName  = query.value(1).toString();
    info.lastName   = query.value(2).toString();
    info.extraInfo  = query.value(3).toString();
    info.added      = query.value(4).toDateTime();
    info.updated    = query.value(5).toDateTime();
    info.balance    = query.value(6).toDouble();
}


//
// Get SLPersonInfo record from SQL database for specified person ID #
//
bool slGetPersonInfo(qint64 id, SLPersonInfo &info)
{
    QSqlQuery query;
    query.prepare(QStringLiteral(
        "SELECT id,first_name,last_name,extra_info,added,updated, "
        "(SELECT TOTAL(value) FROM transactions WHERE transactions.person=people.id) AS balance "
        "FROM people WHERE id=?"));

    query.addBindValue(id);
    query.exec();
    if (!query.next())
        return false;

    slGetPersonInfoRec(query, info);
    query.finish();
    return true;
}


//
// Set stylesheet for given QWidget, and scale fonts etc.
// for some elements based on current UI scale factor.
//
void slSetCommonStyleSheet(QWidget *widget)
{
    // Clamp scale value
    if (settings.uiScale < 0.5f)
        settings.uiScale = 0.5f;

    if (settings.uiScale > 3.0f)
        settings.uiScale = 3.0f;

    // Set the stylesheet
    widget->setStyleSheet(
        QStringLiteral(
        "* { font-size: %1pt; }"
        "QPushButton { font-size: %2pt; padding: 0.25em; }"
        "#button_AddDebt[enabled='true'] { font-size: %3pt; background-color: #900; color: white; }"
        "#button_PayDebt[enabled='true'] { font-size: %3pt; background-color: #090; color: white; }"
        "#button_PayFullDebt[enabled='true'] { background-color: #060; color: white; }"

        "#button_AddDebt[enabled='false'] { font-size: %3pt; background-color: #622; color: black; }"
        "#button_PayDebt[enabled='false'] { font-size: %3pt; background-color: #262; color: black; }"
        "#button_PayFullDebt[enabled='false'] { background-color: #131; color: black; }"

        "#label_PersonName { font-size: %5pt; font-weight: bold;  }"
        "#label_BalanceValue { font-size: %4pt; font-weight: bold; }"
        "#label_EUR { font-size: %4pt; font-weight: bold; }"
        "#edit_Amount { font-size: %4pt; margin: 0.5em; padding: 0.5em; }"
        "#label_NumPeopleValue { color: green; }"
        ).
        arg(12 * settings.uiScale).
        arg(14 * settings.uiScale).
        arg(16 * settings.uiScale).
        arg(18 * settings.uiScale).
        arg(20 * settings.uiScale)
        );
}


//
// Main program begins
//
int main(int argc, char *argv[])
{
    QApplication sapp(argc, argv);
    QSettings tmpst(APP_VENDOR, APP_ID);

    // Read configuration settings
    settings.uiPos = tmpst.value("pos", QPoint(100, 100)).toPoint();
    settings.uiSize = tmpst.value("size", QSize(1000, 600)).toSize();
    settings.uiScale = tmpst.value("scale", 1.0f).toDouble();
    settings.dbBackupMode = tmpst.value("dbBackupMode", BACKUP_NONE).toInt();
    settings.dbBackupURL = tmpst.value("dbBackupURL", QString()).toString();
    settings.dbBackupSecret = tmpst.value("dbBackupSecret", QString()).toString();
    settings.dbLastBackup = tmpst.value("dbLastBackup", QDateTime::fromSecsSinceEpoch(0)).toDateTime();

    // Check commandline arguments for configuring backup settings
    if (argc >= 2 && strcmp(argv[1], "config") == 0)
    {
        settings.dbBackupMode = QString(argv[2]).toInt();
        if (argc >= 5)
        {
            settings.dbBackupURL = QString(argv[3]);
            settings.dbBackupSecret = QString(argv[4]);
        }
    }

    // Also possibility of resetting the UI settings
    if (argc >= 2 && strcmp(argv[1], "reset") == 0)
    {
        settings.uiPos = QPoint(100, 100);
        settings.uiSize = QSize(1000, 600);
        settings.uiScale = 1.0f;
    }

    //
    // Create logfile and data directory if they do not already exist
    //
    settings.dataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
    QDir path(settings.dataPath);
    if (!path.exists(settings.dataPath))
        path.mkpath(settings.dataPath);

    //
    // Initialize / open SQL database connection
    //
    QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE");
    db.setDatabaseName(settings.dataPath + QDir::separator() + APP_SQLITE_FILE);

    if (!db.open())
    {
        slErrorMsg(
            QObject::tr("Tietokantaa ei voitu avata"),
            QObject::tr("Yhteyttä SQL-tietokantaan ei saatu.<br><br>Virhe: %1<br><br>").
            arg(db.lastError().text())
            );
        return 1;
    }

    QSqlQuery query;
    if (!db.tables().contains("people"))
    {
        query.exec(QStringLiteral(
            "CREATE TABLE people (id INTEGER PRIMARY KEY, "
            "first_name VARCHAR(%1) NOT NULL, "
            "last_name VARCHAR(%2) NOT NULL, "
            "extra_info VARCHAR(%3), "
            "added DATETIME NOT NULL, "
            "updated DATETIME NOT NULL)").
            arg(SQL_LEN_FIRST_NAME).
            arg(SQL_LEN_LAST_NAME).
            arg(SQL_LEN_EXTRA_INFO));

        if (!slCheckAndReportSQLError("CREATE TABLE people", query.lastError(), true))
        {
            slErrorMsg(
                QObject::tr("Tietokantataulua ei voitu luoda"),
                QObject::tr("Virhe: %1<br><br>").
                arg(db.lastError().text())
                );
            return 1;
        }
    }

    if (!db.tables().contains("transactions"))
    {
        query.exec(QStringLiteral(
            "CREATE TABLE transactions ("
            "id INTEGER PRIMARY KEY, "
            "person INT NOT NULL, "
            "value REAL, "
            "added DATETIME NOT NULL)"));

        if (!slCheckAndReportSQLError("CREATE TABLE transactions", query.lastError(), true))
        {
            slErrorMsg(
                QObject::tr("Tietokantataulua ei voitu luoda"),
                QObject::tr("Virhe: %1<br><br>").
                arg(db.lastError().text())
                );
            return 1;
        }
    }

    query.finish();

    SyntilistaMainWindow swin;
    swin.show();
    return sapp.exec();
}


//
// Main application window code
//
SyntilistaMainWindow::SyntilistaMainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::SyntilistaMainWindow)
{
    // Setup UI
    ui->setupUi(this);

    // Restore window size and position
    move(settings.uiPos);
    resize(settings.uiSize);

    // Setup application icon and window title
    setWindowIcon(QIcon(QPixmap(":/icon")));
    setWindowTitle(tr("%1 versio %3").
        arg(tr(APP_NAME)).
        arg(APP_VERSION));

    // Setup large logo in the main window
    QPixmap logoImage(":/logo");
    ui->button_LogoImage->setPixmap(logoImage);
    ui->button_LogoImage->setAlignment(Qt::AlignCenter);

    slSetCommonStyleSheet(this);

    // Validator for amount input
    QRegExp vregex("\\d{0,4}[,.]\\d{0,2}|\\d{0,4}");
    ui->edit_Amount->setValidator(new QRegExpValidator(vregex, this));

    // Setup person list filtering and sorting
    peopleSortIndex = 1;
    peopleSortOrder = Qt::AscendingOrder;
    peopleFilter = "";

    model_People = new SLPersonSQLModel();
    updatePersonList();

    ui->tableview_People->setModel(model_People);
    ui->tableview_People->setColumnHidden(0, true);
    ui->tableview_People->setItemDelegate(new QSqlRelationalDelegate(ui->tableview_People));
    ui->tableview_People->verticalHeader()->setVisible(false);
    ui->tableview_People->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
    ui->tableview_People->setSortingEnabled(true);
    ui->tableview_People->sortByColumn(peopleSortIndex, peopleSortOrder);

    connect(
        ui->tableview_People->selectionModel(),
        SIGNAL(currentChanged(const QModelIndex &, const QModelIndex &)),
        this,
        SLOT(selectedPersonChanged(const QModelIndex &, const QModelIndex &)));

    connect(
        ui->tableview_People->horizontalHeader(),
        SIGNAL(sortIndicatorChanged(int, Qt::SortOrder)),
        this,
        SLOT(updateSortOrder(int, Qt::SortOrder)));

    ui->tableview_People->horizontalHeader()->setSortIndicator(1, Qt::AscendingOrder);

    model_Latest = new SLTransactionSQLModel();
    ui->tableview_Latest->setModel(model_Latest);
    ui->tableview_Latest->setItemDelegate(new QSqlRelationalDelegate(ui->tableview_Latest));
    ui->tableview_Latest->verticalHeader()->setVisible(false);
    ui->tableview_Latest->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);

    setActivePerson(-1);

    // Keyboard shortcuts
    new QShortcut(QKeySequence(Qt::CTRL + Qt::Key_Q), this, SLOT(on_button_Quit_clicked()));
    new QShortcut(QKeySequence(Qt::Key_F10), this, SLOT(on_button_Quit_clicked()));

    new QShortcut(QKeySequence(Qt::Key_F5), this, SLOT(on_button_AddPerson_clicked()));
    new QShortcut(QKeySequence(Qt::Key_F6), this, SLOT(on_button_EditPerson_clicked()));
    new QShortcut(QKeySequence(Qt::Key_F8), this, SLOT(on_button_DeletePerson_clicked()));
    new QShortcut(QKeySequence(Qt::Key_F1), this, SLOT(on_button_About_clicked()));
    new QShortcut(QKeySequence(Qt::Key_Escape), this, SLOT(on_button_ClearFilter_clicked()));

    new QShortcut(QKeySequence(QKeySequence::ZoomIn), this, SLOT(changeUIZoomIn()));
    new QShortcut(QKeySequence(QKeySequence::ZoomOut), this, SLOT(changeUIZoomOut()));
    new QShortcut(QKeySequence(Qt::CTRL + Qt::Key_0), this, SLOT(changeUIZoomReset()));
    new QShortcut(QKeySequence(Qt::CTRL + Qt::KeypadModifier + Qt::Key_Plus), this, SLOT(changeUIZoomIn()));
    new QShortcut(QKeySequence(Qt::CTRL + Qt::KeypadModifier + Qt::Key_Minus), this, SLOT(changeUIZoomOut()));
    new QShortcut(QKeySequence(Qt::CTRL + Qt::KeypadModifier + Qt::Key_0), this, SLOT(changeUIZoomReset()));

    new QShortcut(QKeySequence(Qt::Key_PageUp), this, SLOT(selectRowPrev()));
    new QShortcut(QKeySequence(Qt::Key_PageDown), this, SLOT(selectRowNext()));

    new QShortcut(QKeySequence(Qt::CTRL + Qt::Key_Return), this, SLOT(focusDebtEdit()));

    new QShortcut(QKeySequence(Qt::CTRL + Qt::Key_P), this, SLOT(on_button_Print_clicked()));

    // Check for latest successful backup time
    qint64 threshold = 7;
    qint64 delta = settings.dbLastBackup.msecsTo(QDateTime::currentDateTimeUtc());
    if (settings.dbBackupMode != BACKUP_NONE &&
        QDateTime::fromSecsSinceEpoch(0).msecsTo(settings.dbLastBackup) > 0 &&
        delta > (1000 * 60 * 60 * 24) * threshold)
    {
        slErrorMsg(
            tr("<h1>Huomio!</h1>"),
            tr(
            "<p>Edellisestä onnistuneesta tietokannan varmuuskopioinnista on kulunut <b>%1</b> päivää.</p>"
            "<p>On suositeltavaa että kytket laitteen Kampuksen kiinteään verkkoon, että "
            "varmuuskopiointi voidaan suorittaa. Varmuuskopiointia ei voida tehdä langattoman verkon kautta.</p>"
            ).
            arg(delta / (1000 * 60 * 60 * 24)));
    }
}


//
// Application main window destructor
//
SyntilistaMainWindow::~SyntilistaMainWindow()
{
    QSettings tmpst(APP_VENDOR, APP_ID);

    // Save window size and position
    tmpst.setValue("pos", pos());
    tmpst.setValue("size", size());

    // Other settings
    tmpst.setValue("scale", settings.uiScale);
    tmpst.setValue("dbBackupMode", settings.dbBackupMode);
    tmpst.setValue("dbBackupURL", settings.dbBackupURL);
    tmpst.setValue("dbBackupSecret", settings.dbBackupSecret);

    // Free resources
    delete ui;
    delete model_People;
    delete model_Latest;

    // Commit and close database
    QSqlDatabase::database().commit();
    QSqlDatabase::database().close();

    // Back up the database
    if (settings.dbBackupMode != BACKUP_NONE)
        backupDatabase();
    else
    {
        slLog("INFO",
            QStringLiteral("Database backup mode is NONE, not performing backup."));
    }
}


void SyntilistaMainWindow::backupSuccess()
{
    QSettings tmpst(APP_VENDOR, APP_ID);
    slLog("INFO", QStringLiteral("Backup successful."));

    tmpst.setValue("dbLastBackup", QDateTime::currentDateTimeUtc());
}


void SyntilistaMainWindow::backupDatabase()
{
    QString dbFilename = settings.dataPath + QDir::separator() + APP_SQLITE_FILE;
    QString backupFilename = APP_SQLITE_FILE;
    backupDialog = NULL;

    if (settings.dbBackupURL == QString() || settings.dbBackupURL == "")
    {
        slLog("ERROR",
            QStringLiteral("Database backup URL not set in configuration."));
        return;
    }

    if (settings.dbBackupSecret == QString() || settings.dbBackupSecret == "")
    {
        slLog("ERROR",
            QStringLiteral("Database backup secret key not set in configuration."));
        return;
    }

    if (settings.dbBackupMode == BACKUP_HTTP)
    {
#ifdef USE_QTHTTP
        // Check for network access
        httpBackupReply = NULL;

        QNetworkAccessManager *manager = new QNetworkAccessManager();
/*
        // NOTE XXX! For some reason the manager returns not accessible under Wine
        // and possibly some version(s) of Windows .. not sure why, thus commented
        // out for now.
        if (manager->networkAccessible() != QNetworkAccessManager::Accessible)
        {
            slLog("ERROR", QStringLiteral("Network not available, cannot backup the database."));
            return;
        }
*/

        // Attempt to open the database file
        QFile *file = new QFile(dbFilename);
        if (!file->open(QIODevice::ReadOnly))
        {
            slLog("ERROR",
                QStringLiteral("Failed to open database file '%1' for backup.").
                arg(dbFilename));
            return;
        }

        // Okay, we seem to be "go" ..
        slLog("INFO",
            QStringLiteral("Attempting database backup from '%1' to '%2'.").
            arg(dbFilename).arg(settings.dbBackupURL));

        // Create the HTTP POST request
        QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);

        // The "secret" key as POST parameter
        QHttpPart postPart;
        postPart.setHeader(QNetworkRequest::ContentDispositionHeader,
            QVariant("form-data; name=\"secret\";"));
        postPart.setBody(QByteArray(settings.dbBackupSecret.toUtf8()));

        // Actual data as binary octet-stream
        QHttpPart dataPart;
        dataPart.setHeader(QNetworkRequest::ContentTypeHeader,
            QVariant("binary/octet-stream"));

        dataPart.setHeader(QNetworkRequest::ContentDispositionHeader,
            QVariant("form-data; name=\"file\"; filename=\""+ backupFilename +"\""));

        dataPart.setBodyDevice(file);
        file->setParent(multiPart); // we cannot delete the QFile object now, so delete it with the multiPart

        multiPart->append(postPart);
        multiPart->append(dataPart);

        // Attempt to POST the whole thing
        QUrl url(settings.dbBackupURL);
        QNetworkRequest request(url);
        httpBackupReply = manager->post(request, multiPart);
        multiPart->setParent(httpBackupReply);

        // Connect signals
        connect(
            httpBackupReply,
            SIGNAL(finished()),
            this,
            SLOT(httpBackupFinished()));

        connect(
            httpBackupReply,
            SIGNAL(uploadProgress(qint64, qint64)),
            this,
            SLOT(httpBackupProgress(qint64, qint64)));

        connect(
            httpBackupReply,
            SIGNAL(error(QNetworkReply::NetworkError)),
            this,
            SLOT(httpBackupError(QNetworkReply::NetworkError)));
#else
        // Disabled
        slLog("ERROR", QStringLiteral("Backup method is HTTP/HTTPS, but support is not compiled in!"));
        return;
#endif
    }
    else
    {
        slLog("ERROR", QStringLiteral("Database backup mode is INVALID! Not performing backup!"));
        return;
    }

    // Create progress dialog
    backupDialog = new QProgressDialog(
        tr("Varmuuskopioidaan tietokantaa ..."),
        QString(),
        0,
        100,
        this);

    backupDialog->setAttribute(Qt::WA_DeleteOnClose);
    backupDialog->setAutoClose(false);
    backupDialog->setWindowModality(Qt::ApplicationModal);
    backupDialog->exec();
}


#ifdef USE_QTHTTP
void SyntilistaMainWindow::httpBackupProgress(qint64 bytesSent, qint64 bytesTotal)
{
    if (bytesTotal > 0)
    {
        slLog("INFO",
            QStringLiteral("Backup sent %1 / %2 bytes.").
            arg(bytesSent).
            arg(bytesTotal));

        backupDialog->setValue((bytesSent * 100) / bytesTotal);
    }
}


void SyntilistaMainWindow::httpBackupError(QNetworkReply::NetworkError code)
{
    slLog("ERROR",
        QStringLiteral("Backup failed with network error %1.").
        arg(code)
        );
}


void SyntilistaMainWindow::httpBackupFinished()
{
    if (httpBackupReply)
    {
        QVariant status = httpBackupReply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
        if (status.isValid())
        {
            int code = status.toInt();
            switch (code)
            {
                case 200:
                    backupSuccess();
                    break;

                case 403:
                    slLog("ERROR",
                        QStringLiteral("Backup server authentication failed. Wrong secret or other invalid settings."));
                    break;

                default:
                    slLog("ERROR",
                        QStringLiteral("Backup server responded with error:\n")+
                        QString::fromUtf8(httpBackupReply->readAll()));
                    break;
            }
        }
    }
    else
    {
        slLog("WARNING",
            QStringLiteral("Backup finished prematurely (failed)."));
    }

    backupDialog->close();
}
#endif


//
// Helper function for showing messages in the statusbar/line
//
void SyntilistaMainWindow::statusMsg(const QString &msg)
{
    slLog("STATUS", msg);
    ui->statusbar->showMessage(msg);
}


//
// Window scale / zoom changing
//
void SyntilistaMainWindow::changeUIZoomIn()
{
    settings.uiScale += 0.1f;
    slSetCommonStyleSheet(this);
}


void SyntilistaMainWindow::changeUIZoomOut()
{
    settings.uiScale -= 0.1f;
    slSetCommonStyleSheet(this);
}


void SyntilistaMainWindow::changeUIZoomReset()
{
    settings.uiScale = 1.0f;
    slSetCommonStyleSheet(this);
}


//
// Slot for changed selection of person entry
//
void SyntilistaMainWindow::selectedPersonChanged(const QModelIndex &curr, const QModelIndex &prev)
{
    (void) prev;
    int row = curr.row();
    if (row >= 0)
    {
        const QAbstractItemModel *model = curr.model();
        setActivePerson(model->data(model->index(row, 0)).toInt());
        focusDebtEdit();
    }
    else
        setActivePerson(-1);
}


//
// Set currently active person to given SQL id
//
void SyntilistaMainWindow::setActivePerson(qint64 id)
{
    currPerson.id = id;

    ui->button_EditPerson->setEnabled(id >= 0);

    if (id >= 0)
    {
        if (!slGetPersonInfo(id, currPerson))
        {
            statusMsg(tr("Virhe! Ei henkilöä ID:llä #%1").arg(id));
        }
        else
        {
            ui->personGB->setEnabled(true);
            ui->label_PersonName->setText(currPerson.lastName +", "+ currPerson.firstName);

            ui->label_BalanceValue->setText(slMoneyValueToStr(currPerson.balance));
            ui->label_BalanceValue->setStyleSheet(currPerson.balance < 0 ? "color: red;" : "color: green;");
            ui->button_PayFullDebt->setEnabled(currPerson.balance < 0);

            QSqlQuery query;
            query.prepare(QStringLiteral("SELECT id,value,added FROM transactions WHERE person=? ORDER BY added DESC LIMIT 5"));
            query.addBindValue(id);
            query.exec();
            slCheckAndReportSQLError("SELECT transactions for tableview_Latest", query.lastError());

            model_Latest->setQuery(query);

            model_Latest->setHeaderData(0, Qt::Horizontal, tr("ID"));
            model_Latest->setHeaderData(1, Qt::Horizontal, tr("Summa"));
            model_Latest->setHeaderData(2, Qt::Horizontal, tr("Aika"));

            ui->tableview_Latest->setModel(model_Latest);
            ui->tableview_Latest->setColumnHidden(0, true);
            ui->tableview_Latest->verticalHeader()->setVisible(false);
            ui->tableview_Latest->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);

            slSetCommonStyleSheet(this);
            return; // Ugly
        }
    }

    // In case of id < 0 or errors ..
    ui->personGB->setEnabled(false);
    ui->edit_Amount->clear();
    ui->label_BalanceValue->setText("--");
    ui->label_BalanceValue->setStyleSheet(NULL);
    ui->label_PersonName->setText("???");
    ui->tableview_Latest->setModel(NULL);
    slSetCommonStyleSheet(this);
}


//
// Slot for changing person list sort order
//
void SyntilistaMainWindow::updateSortOrder(int index, Qt::SortOrder order)
{
    peopleSortIndex = index;
    peopleSortOrder = order;
    updatePersonList();
}


void SyntilistaMainWindow::on_button_Quit_clicked()
{
    close();
}


void SyntilistaMainWindow::on_button_About_clicked()
{
    new AboutWindow(this);
}


void SyntilistaMainWindow::on_button_DeletePerson_clicked()
{
    if (currPerson.id <= 0)
    {
        statusMsg(tr("Ei valittua henkilöä!"));
        return;
    }

    // Internal sanity check
    SLPersonInfo info;
    if (!slGetPersonInfo(currPerson.id, info))
    {
        statusMsg(tr("Virhe! Ei henkilöä ID:llä #%1").arg(currPerson.id));
        return;
    }

    // Ask for confirmation
    QMessageBox dlg;
    slSetCommonStyleSheet(&dlg);
    dlg.setText(tr("Varmistus"));
    dlg.setInformativeText(tr(
        "<h3>Haluatko varmasti poistaa henkilön:</h3>"
        "<br>"
        "<b>'%1, %2'</b> <i>(ID #%3)</i>?<br>"
        "<br>"
        "<span style='color:#f00;'>Tämä poistaa sekä henkilön ja hänen koko tapahtumahistoriansa PYSYVÄSTI!</span>"
        "<br>").
        arg(info.lastName).arg(info.firstName).arg(info.id));

    dlg.setTextFormat(Qt::RichText);
    dlg.setIcon(QMessageBox::Question);
    dlg.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
    dlg.setButtonText(QMessageBox::Yes, tr("Kyllä"));
    dlg.setButtonText(QMessageBox::No, tr("Ei / peruuta"));
    dlg.setDefaultButton(QMessageBox::No);

    if (dlg.exec() == QMessageBox::Yes)
    {
        int rv = model_People->deletePerson(info.id);
        updatePersonList();
        setActivePerson(-1);
        if (rv != 0)
        {
            slErrorMsg(tr("SQL-tietokantavirhe"),
                tr("Henkilön tietoja poistettaessa tapahtui virhe #%1.").
                arg(rv));
        }
        else
        {
            statusMsg(tr("Henkilö '%1 %2' (ID #%3) poistettu.").
                arg(info.firstName).arg(info.lastName).
                arg(info.id));
        }
    }
}


void SyntilistaMainWindow::on_button_AddPerson_clicked()
{
    EditPerson *person = new EditPerson(this);
    person->setPerson(-1);
}


void SyntilistaMainWindow::on_button_EditPerson_clicked()
{
    if (currPerson.id >= 0)
    {
        EditPerson *person = new EditPerson(this);
        person->setPerson(currPerson.id);
    }
}


void SyntilistaMainWindow::on_tableview_People_doubleClicked(const QModelIndex &curr)
{
    int row = curr.row();
    if (row >= 0)
    {
        const QAbstractItemModel *model = curr.model();
        setActivePerson(model->data(model->index(row, 0)).toInt());

        EditPerson *person = new EditPerson(this);
        person->setPerson(currPerson.id);
    }
    else
        setActivePerson(-1);
}


void SyntilistaMainWindow::on_button_ClearFilter_clicked()
{
    ui->edit_PersonFilter->clear();
    ui->edit_PersonFilter->setFocus(Qt::ShortcutFocusReason);
}


void SyntilistaMainWindow::focusDebtEdit()
{
    if (currPerson.id >= 0)
        ui->edit_Amount->setFocus(Qt::ShortcutFocusReason);
}


void SyntilistaMainWindow::changeSelectedRow(const int delta)
{
    QItemSelectionModel *sel = ui->tableview_People->selectionModel();
    int prow = sel->currentIndex().row();
    int nrow = prow + delta;
    if (nrow < 0)
        nrow = 0;
    else
    if (nrow >= model_People->rowCount())
        nrow = model_People->rowCount() - 1;

    if (nrow != prow)
    {
        // If row changed, set current index
        sel->setCurrentIndex(model_People->index(nrow, 0),
            QItemSelectionModel::ClearAndSelect|QItemSelectionModel::Rows);

        // The column must be a visible one (not set "hidden", as the ID field is)
        // thus we use column index of 1 here for the QModelIndex()
        ui->tableview_People->scrollTo(model_People->index(nrow, 1));
    }
}


void SyntilistaMainWindow::selectRowPrev()
{
    changeSelectedRow(-1);
}


void SyntilistaMainWindow::selectRowNext()
{
    changeSelectedRow(1);
}


//
// Update visible person list/query based on the current
// filtering and sorting settings.
//
void SyntilistaMainWindow::updatePersonList()
{
    static const QString queryBase =
        QStringLiteral("SELECT id,last_name,first_name,"
        "(SELECT TOTAL(value) FROM transactions WHERE transactions.person=people.id) AS balance,"
        "updated FROM people");

    QString queryOrderDir, queryOrderBy;

    // Sort order
    if (peopleSortOrder == Qt::AscendingOrder)
        queryOrderDir = QStringLiteral("ASC");
    else
        queryOrderDir = QStringLiteral("DESC");

    // Sort by which column
    switch (peopleSortIndex)
    {
        case 1:
        case 2:
            queryOrderBy =
                QStringLiteral(" ORDER BY last_name ") + queryOrderDir +
                QStringLiteral(",first_name ") + queryOrderDir;
            break;

        case 3:
            queryOrderBy =
                QStringLiteral(" ORDER BY balance ") + queryOrderDir;
            break;

        case 4:
            queryOrderBy =
                QStringLiteral(" ORDER BY updated ") + queryOrderDir;
            break;

        default:
            queryOrderBy = "";
    }

    // Are we filtering or not?
    QSqlQuery query;
    if (peopleFilter != "")
    {
        // Filter by name(s)
        QString tmp = "%"+ peopleFilter +"%";
        query.prepare(queryBase + QStringLiteral(" WHERE first_name LIKE ? OR last_name LIKE ?") + queryOrderBy);

        query.addBindValue(tmp);
        query.addBindValue(tmp);
    }
    else
    {
        // No filter
        query.prepare(queryBase + queryOrderBy);
    }

    // Execute the query and update model
    slCheckAndReportSQLError("updatePersonList() before exec", query.lastError());
    query.exec();
    slCheckAndReportSQLError("updatePersonList() after exec", query.lastError());

    model_People->setQuery(query);

    model_People->setHeaderData(0, Qt::Horizontal, tr("ID"));
    model_People->setHeaderData(1, Qt::Horizontal, tr("Sukunimi"));
    model_People->setHeaderData(2, Qt::Horizontal, tr("Etunimi"));
    model_People->setHeaderData(3, Qt::Horizontal, tr("Tase"));
    model_People->setHeaderData(4, Qt::Horizontal, tr("Muutettu"));

    updateMiscValues();
}


//
// Update some values in the UI
//
void SyntilistaMainWindow::updateMiscValues()
{
    // Update total balance value
    QSqlQuery query;
    query.prepare(QStringLiteral("SELECT TOTAL(value) FROM transactions AS balance"));
    query.exec();
    if (slCheckAndReportSQLError("updateMiscValues() get total balance query", query.lastError()) &&
        query.next())
    {
        totalBalance = query.value(0).toDouble();;
        ui->label_TotalBalanceValue->setText(slMoneyValueToStr(totalBalance));
        ui->label_TotalBalanceValue->setStyleSheet(totalBalance < 0 ? "color: red;" : "color: green;");
    }

    // Update number of people
    query.finish();
    query.prepare(QStringLiteral("SELECT COUNT(*) FROM people"));
    query.exec();
    if (slCheckAndReportSQLError("updateMiscValues() get people count", query.lastError()) &&
        query.next())
    {
        totalPeople = query.value(0).toInt();
        ui->label_NumPeopleValue->setText(query.value(0).toString());
    }
}


//
// Update the list of people when filter parameter changes
//
void SyntilistaMainWindow::on_edit_PersonFilter_textChanged(const QString &str)
{
    peopleFilter = slCleanupStr(str);
    updatePersonList();
}


//
// Add one transaction to given person id
//
int SyntilistaMainWindow::addTransaction(qint64 id, double value, SLPersonInfo &info)
{
    // Sanity check: Check if the given person ID exists
    if (!slGetPersonInfo(id, info))
        return -1;

    QSqlDatabase::database().transaction();

    // Add transaction entry
    QSqlQuery query;
    query.prepare(QStringLiteral("INSERT INTO transactions (person,value,added) VALUES (?,?,?)"));
    query.addBindValue(id);
    query.addBindValue(value);
    query.addBindValue(QDateTime::currentDateTimeUtc());
    query.exec();
    if (!slCheckAndReportSQLError(QStringLiteral("addTransaction(%1, %2)").arg(id).arg(value), query.lastError(), true))
    {
        QSqlDatabase::database().rollback();
        return -2;
    }

    // Update person record timestamp
    query.prepare(QStringLiteral("UPDATE people SET updated=? WHERE id=?"));
    query.addBindValue(QDateTime::currentDateTimeUtc());
    query.addBindValue(id);
    query.exec();
    if (!slCheckAndReportSQLError("addTransaction update timestamp", query.lastError(), true))
    {
        QSqlDatabase::database().rollback();
        return -3;
    }

    QSqlDatabase::database().commit();

    updateMiscValues();

    return 0;
}


int SyntilistaMainWindow::addTransactionGUI(qint64 id, bool debt, double value)
{
    SLPersonInfo info;

    // Check if person is selected
    if (id <= 0)
        return -1;

    // Check value
    if (value == 0)
    {
        QString tmp = debt ? tr("lisätty") : tr("vähennetty");
        statusMsg(tr("Velkaa ei %1 koska summaa ei määritetty.").arg(tmp));
        return 1;
    }

    // Perform transaction insert
    int ret = addTransaction(id, debt ? -value : value, info);
    if (ret == 0)
    {
        // All ok, clear amount entry and update person data
        ui->edit_Amount->clear();
        if (info.id == currPerson.id)
            setActivePerson(info.id);

        model_People->updateModel();

        if (debt)
        {
            // Debt was added
            statusMsg(
                tr("Lisättiin velkaa %1 EUR henkilölle '%2 %3' (#%4).").
                arg(slMoneyValueToStr(value)).
                arg(info.firstName).
                arg(info.lastName).
                arg(info.id));
        }
        else
        {
            // Debt was reduced
            statusMsg(
                tr("Vähennettiin velkaa %1 EUR henkilöltä '%2 %3' (#%4).").
                arg(slMoneyValueToStr(value)).
                arg(info.firstName).
                arg(info.lastName).
                arg(info.id));
        }
    }
    else
    {
        slErrorMsg(
            tr("SQL-tietokantavirhe"),
            tr("Tietokantaan tapahtumaa lisättäessa tapahtui virhe #%1.").
            arg(ret));
    }

    return ret;
}


void SyntilistaMainWindow::on_button_AddDebt_clicked()
{
    addTransactionGUI(currPerson.id, true, slMoneyStrToValue(ui->edit_Amount->text()));
}


void SyntilistaMainWindow::on_button_PayDebt_clicked()
{
    addTransactionGUI(currPerson.id, false, slMoneyStrToValue(ui->edit_Amount->text()));
}


void SyntilistaMainWindow::on_button_PayFullDebt_clicked()
{
    // Sanity check that there is a selected person
    if (currPerson.id <= 0)
    {
        statusMsg(tr("Ei valittua henkilöä!"));
        return;
    }

    // Check the balance ..
    if (currPerson.balance < 0)
    {
        // And ask confirmation that user really wants to clear the full debt
        QMessageBox dlg;
        slSetCommonStyleSheet(&dlg);
        dlg.setText(tr("Varmistus"));
        dlg.setInformativeText(tr(
            "<h3>Haluatko maksaa henkilön koko velan?</h3>"
            "<br>"
            "<b>'%1, %2'</b>, velka <span style='color:#f00;'><b>%4 EUR</b></span>"
            "<br>").
            arg(currPerson.lastName).arg(currPerson.firstName).
            arg(slMoneyValueToStr(currPerson.balance)));

        dlg.setTextFormat(Qt::RichText);
        dlg.setIcon(QMessageBox::Question);
        dlg.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
        dlg.setButtonText(QMessageBox::Yes, tr("Kyllä"));
        dlg.setButtonText(QMessageBox::No, tr("Ei / peruuta"));
        dlg.setDefaultButton(QMessageBox::No);

        if (dlg.exec() == QMessageBox::Yes)
        {
            addTransactionGUI(currPerson.id, false, -currPerson.balance);
        }
    }
    else
    {
        statusMsg(
            tr("Valitulla henkilöllä '%1, %2' ei ole velkaa.").
            arg(currPerson.lastName).
            arg(currPerson.firstName));
    }
}


//
// Edit person dialog
//
EditPerson::EditPerson(QWidget *parent) :
    QDialog(parent),
    ui(new Ui::EditPerson)
{
    ui->setupUi(this);

    slSetCommonStyleSheet(this);

    setModal(true);
    setAttribute(Qt::WA_DeleteOnClose);
    show();
    activateWindow();
    raise();
    setFocus();

    model_Transactions = new SLTransactionSQLModel();
    ui->tableview_Transactions->setModel(model_Transactions);
    ui->tableview_Transactions->setItemDelegate(new QSqlRelationalDelegate(ui->tableview_Transactions));
    ui->tableview_Transactions->verticalHeader()->setVisible(false);
    ui->tableview_Transactions->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);

    ui->edit_FirstName->setMaxLength(SQL_LEN_FIRST_NAME);
    ui->edit_LastName->setMaxLength(SQL_LEN_LAST_NAME);

    connect(
        ui->textedit_ExtraInfo,
        SIGNAL(textChanged()),
        this,
        SLOT(on_textedit_ExtraInfo_textChanged()));

    validateForm();
}


EditPerson::~EditPerson()
{
    delete ui;
    delete model_Transactions;
}


void EditPerson::statusMsg(const QString &msg)
{
    // Pass the status message to main window
    dynamic_cast<SyntilistaMainWindow *>(parent())->statusMsg(msg);
}


bool EditPerson::validateForm()
{
    selPerson.firstName = slCleanupStr(ui->edit_FirstName->text());
    selPerson.lastName = slCleanupStr(ui->edit_LastName->text());
    selPerson.extraInfo = ui->textedit_ExtraInfo->document()->toPlainText();
    bool extraInfoValid = selPerson.extraInfo.length() < SQL_LEN_EXTRA_INFO;

    ui->textedit_ExtraInfo->setStyleSheet(!extraInfoValid ? "background-color: red;" : NULL);
    ui->edit_FirstName->setStyleSheet(selPerson.firstName == "" ? "background-color: red;" : NULL);
    ui->edit_LastName->setStyleSheet(selPerson.lastName == "" ? "background-color: red;" : NULL);

    return selPerson.firstName != "" && selPerson.lastName != "" && extraInfoValid;
}


void EditPerson::on_button_Cancel_clicked()
{
    close();
}


void EditPerson::on_button_OK_clicked()
{
    //
    // Check form validation
    //
    if (!validateForm())
    {
        slErrorMsg(
            tr("Virhe!"),
            tr("Vaaditut kentät (etunimi, sukunimi) eivät ole täytetty tai lisätietojen pituus on liian suuri."));

        return;
    }

    if (selPerson.id >= 0)
    {
        //
        // We are in update/edit person mode, thus we check if the
        // first/last name have changed and if there is someone with
        // different ID and same names.
        //
        QSqlQuery person;
        person.prepare(QStringLiteral("SELECT * FROM people WHERE id <> ? AND first_name=? AND last_name=?"));
        person.addBindValue(selPerson.id);
        person.addBindValue(selPerson.firstName);
        person.addBindValue(selPerson.lastName);
        person.exec();

        slCheckAndReportSQLError("SELECT check for existing person by same name (UPDATE)", person.lastError());

        if (person.next())
        {
            // There exists another person with that name
            slErrorMsg(
                tr("Virhe!"),
                tr("Ei pysty! Samalla nimellä '%1 %2' on olemassa jo henkilö!").
                arg(selPerson.firstName).arg(selPerson.lastName));
            return;
        }

        // Allest klar, update the person data
        dynamic_cast<SyntilistaMainWindow *>(parent())->model_People->updatePerson(selPerson);
        dynamic_cast<SyntilistaMainWindow *>(parent())->setActivePerson(selPerson.id);

        statusMsg(tr("Päivitettiin henkilö '%1 %2' (#%3).").
            arg(selPerson.firstName).arg(selPerson.lastName).arg(selPerson.id));
    }
    else
    {
        //
        // We are in "add new person" mode, check if there exists
        // someone with same first+last name.
        //
        QSqlQuery person;
        person.prepare("SELECT * FROM people WHERE first_name=? AND last_name=?");
        person.addBindValue(selPerson.firstName);
        person.addBindValue(selPerson.lastName);
        person.exec();

        slCheckAndReportSQLError("SELECT check for existing person by same name (ADD)", person.lastError());

        if (person.next())
        {
            // There exists a record with same name
            slErrorMsg(
                tr("Virhe!"),
                tr("Ei pysty! Samalla nimellä '%1 %2' on olemassa jo henkilö!").
                arg(selPerson.firstName).arg(selPerson.lastName));

            return;
        }

        // Attempt to add a person
        qint64 nid = dynamic_cast<SyntilistaMainWindow *>(parent())->model_People->addPerson(selPerson);
        if (nid < 0)
        {
            slErrorMsg(
                tr("Virhe!"),
                tr("Tietokannan käsittelyssä tapahtui virhe (#%1).").
                arg(nid));
        }
        else
        {
            dynamic_cast<SyntilistaMainWindow *>(parent())->updatePersonList();
            dynamic_cast<SyntilistaMainWindow *>(parent())->setActivePerson(nid);
            dynamic_cast<SyntilistaMainWindow *>(parent())->focusDebtEdit();

            statusMsg(tr("Lisättiin uusi henkilö '%1 %2'.").
                arg(selPerson.firstName).arg(selPerson.lastName));
        }
    }

    close();
}


void EditPerson::on_edit_FirstName_textChanged(const QString &arg1)
{
    (void) arg1;
    validateForm();
}


void EditPerson::on_edit_LastName_textChanged(const QString &arg1)
{
    (void) arg1;
    validateForm();
}


void EditPerson::on_textedit_ExtraInfo_textChanged()
{
    validateForm();
}


void EditPerson::clearForm()
{
    ui->edit_FirstName->clear();
    ui->edit_LastName->clear();
    ui->textedit_ExtraInfo->document()->clear();
    ui->edit_FirstName->setFocus();
}


//
// Set the person to be edited
//
void EditPerson::setPerson(qint64 id)
{
    selPerson.id = id;

    if (id >= 0)
    {
        SLPersonInfo pinfo;
        if (!slGetPersonInfo(id, pinfo))
        {
            statusMsg(tr("Virhe! Ei henkilöä ID:llä #%1").arg(id));
            // Intentional fall-through below
        }
        else
        {
            ui->edit_FirstName->setText(pinfo.firstName);
            ui->edit_LastName->setText(pinfo.lastName);
            ui->textedit_ExtraInfo->document()->setPlainText(pinfo.extraInfo);
            ui->label_AddedValue->setText(slDateTimeToStr(pinfo.added));

            QSqlQuery query;
            query.prepare(QStringLiteral("SELECT id,value,added FROM transactions WHERE person=? ORDER BY added DESC"));
            query.addBindValue(pinfo.id);
            query.exec();
            slCheckAndReportSQLError("SELECT transactions for tableview_Transactions", query.lastError());

            model_Transactions->setQuery(query);

            model_Transactions->setHeaderData(0, Qt::Horizontal, tr("ID"));
            model_Transactions->setHeaderData(1, Qt::Horizontal, tr("Summa"));
            model_Transactions->setHeaderData(2, Qt::Horizontal, tr("Aika"));

            ui->tableview_Transactions->setModel(model_Transactions);
            ui->tableview_Transactions->setColumnHidden(0, true);

            return; // Ugly
        }
    }

    // In case of id < 0 or errors ..
    clearForm();
    ui->tableview_Transactions->setModel(NULL);
}


//
// About window
//
AboutWindow::AboutWindow(QWidget *parent) :
    QDialog(parent),
    ui(new Ui::AboutWindow)
{
    ui->setupUi(this);

    ui->label_Logo->setPixmap(QPixmap(QStringLiteral(":/icon")));
    ui->label_Logo->setAlignment(Qt::AlignCenter);

    ui->label_About->setOpenExternalLinks(true);
    ui->label_About->setWordWrap(true);
    ui->label_About->setTextFormat(Qt::RichText);
    ui->label_About->setText(tr(
        "<h1>%1 v%2</h1>"
        "<p>"
        "<b>Ohjelmoinut ja kehittänyt Matti Hämäläinen &lt;ccr@tnsp.org&gt;<br>"
        "(C) Copyright 2017-2018 Tecnic Software productions (TNSP)</b>"
        "</p>"
        "<p>"
        "Kehitetty Raahen kaupungin Hanketoiminta ja Kehittäminen -yksikön "
        "alaisuudessa Café Kampuksen käyttöön."
        "</p>"
        "<p>"
        "Ohjelma ja sen lähdekoodi ovat uudemman BSD-tyylisen lisenssin alaisia. "
        "Lue ohjelman mukana tullut tiedosto \"COPYING\" (tai \"COPYING.txt\") "
        "nähdäksesi täydelliset lisenssiehdot."
        "</p>"
        "<p>AppDataPath: <a href=\"file:///%3\">%3</a></p>"
        ).
        arg(tr(APP_NAME)).
        arg(APP_VERSION).
        arg(settings.dataPath)
        );

    ui->label_ShortCuts->setText(tr(
        "<h1>Pikanäppäimet</h1>"
        "<table>"
        "<tr><td><b>F1</b></td><td>Tämä tietoikkuna</td></tr>"
        "<tr><td><b>CTRL + Q</b></td><td>Ohjelman lopetus</td></tr>"
        "<tr><td><b>CTRL + Page Up</b></td><td>Suurenna ohjelman tekstejä/käyttöliittymää</td></tr>"
        "<tr><td><b>CTRL + Page Down</b></td><td>Pienennä ohjelman tekstejä/käyttöliittymää</td></tr>"
        "<tr></tr>"
        "<tr><td><b>Esc</b></td><td>Tyhjennä 'Etsi / suodata' kenttä ja siirry siihen</td></tr>"
        "<tr><td><b>CTRL + Enter</b></td><td>Siirry summan syöttökenttään</td></tr>"
        "<tr><td><b>Page Up</b></td><td>Siirry ylös henkilölistassa</td></tr>"
        "<tr><td><b>Page Down</b></td><td>Siirry alas henkilölistassa</td></tr>"
        "<tr></tr>"
        "<tr><td><b>F5</b></td><td>Lisää uusi henkilö</td></tr>"
        "<tr><td><b>F6</b></td><td>Muokkaa henkilöä</td></tr>"
        "<tr><td><b>F8</b></td><td>Poista henkilö</td></tr>"
        "</table>"
        ));

    setModal(true);
    setAttribute(Qt::WA_DeleteOnClose);
    show();
    activateWindow();
    raise();
    setFocus();
}


AboutWindow::~AboutWindow()
{
    delete ui;
}


void AboutWindow::on_button_Close_clicked()
{
    close();
}