STEP13：Laravel5.7 + Vue2.5 で給与明細：給与明細のＰＤＦを自動生成する(GitHubで公開中)

アップロードしたCSVから社員の給与明細PDFファイルを自動生成してみます

CSVで100人分のデータをアップすれぱ、100人分の給与明細PDFが１発で出来上がります
明細のレイアウトはHTML(LaravelのBlade)で作るので自由に変更可能

明細の「項目名」はCSVで指定できるようにするので、特定月のみしか使わないような項目を毎月表示させる必要もありません
(年末調整とか賞与とか。。)

社員が自分の明細を見るユーザ側の「給与明細の電子交付」機能は別の記事で書く予定
この記事は管理者側の機能のみです


ＰＤＦの生成は TCPDF を利用
　利用方法を調査した記事はこちら
　[STEP09：Laravel5.7 でデータを埋め込んだPDFを生成する](https://qiita.com/nobu-maple/items/d60189231e9db9cb6b5a)



環境設定他関連記事はこちら
[Laravel + Vue + Vuetify で業務サイト作ってみる](https://qiita.com/nobu-maple/items/d1e7170d62ab07890a7a)



今回は、記事がすごく長いです。。
ソースが長すぎなんですよね。。。



# CSVレイアウト

CSVは１行目にヘッダー情報を書き込み
このヘッダー情報がそのまま明細の「項目名」として出力されます
２行目以降に明細のデータとして作成すること
最大行数はプログラム上で特に決めてないです。。。が、PHPのアップロードファイルサイズ指定の規制に引っかからない範囲で
（upload_max_filesizeとか）

CSVの各行の項目は以下のように作成すること
１カラム目に「誰の」明細かということで社員を特定するIDとしてログインIDを指定。　これ必須
２カラム目に「ファイル名」を指定。　通常は空にしておくとデフォルトのファイル名になるけど、例えば内容を修正した後にアップした場合に「訂正版」などの名前を付けたい場合に指定する。
３カラム目以降は明細の金額やら時間やらの情報を詰め込むこと
最大カラム数もプログラム上で特には決めてないです。。。が、明細情報は暗号化してDBに保存するのでDB制限がかかるかな
（MySQL の Text型 なので 65535バイトまでかな？　足りない場合は LongText型とかにしたら４GBまで増やせるはず）


サンプル(表）

|loginID|file|総支給額| 基本給|交通費|支給額合計|雇用保険料|健康保険料|厚生年金保険料|控除額合計|
|:------|:---|-------:|------:|-----:|---------:|---------:|---------:|-------------:|---------:|
|1234567|    |   1,000|  2,000| 3,000|     4,000|     5,000|     6,000|         7,000|     8,000|
|ABCDEFG|補正|   1,000|  2,000| 3,000|     4,000|     5,000|     6,000|         7,000|     8,000|

３カラム目の「総支給額」からが明細データ


サンプル（実ファイル）
"loginID","file","総支給額","基本給","交通費","支給額合計","雇用保険料","健康保険料","厚生年金保険料","控除額合計"
"1234567","","1,000","2,000","3,000","4,000","5,000","6,000","7,000","8,000"
"ABCDEFG","補正","1,000","2,000","3,000","4,000","5,000","6,000","7,000","8,000"

↑↑
こんな感じで作成


# 給与明細レイアウト

Bladeでレイアウトを組みます
とりあえずどこかのテンプレートサイトで見つけてきたレイアウトっぽいのを作ってみました

![z31.png](https://qiita-image-store.s3.amazonaws.com/0/153259/e9be464d-703d-d095-4a22-8d1f303750ed.png)



```html:resources/views/m31_02.blade.php
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
  .w25  { width: 25%; }
  .w50  { width: 50%; }
  .w75  { width: 75%; }
  .w100 { width: 100%; }
  .left   { text-align: left; }
  .center { text-align: center; }
  .right  { text-align: right; }
  .bk_white   { background-color: #fff; }
  .bk_primary { background-color: #3490dc; }
  .bk_table   { background-color: #eee; }
  .font_S { font-size: 8; }
  .font_M { font-size: 10; }
  .font_L { font-size: 12; }
</style>
</head>
<body class="bk_white"
  ><div
   ><table border="0" width="100%" cellpadding="10" cellspacing="0">
      <!-- タイトル：社名＋給与明細　　対象年月-->
      <tr class="bk_primary font_L">
        <td class="w75 left">@isset($company) {{ $company }} @else 会社名不明 @endisset　　給与明細</td>
        <td class="w25 right">@isset($ym) {{ $ym }} @else 対象年月不明 @endisset </td>
      </tr>
    </table>
  </div>

  <div
   ><table border="1" width="100%" cellpadding="2" cellspacing="0">
      <tr class="font_M">
        <!-- 氏名 -->
        <td class="w25 left   bk_primary">&nbsp;&nbsp;氏名</td>
        <td class="w25 center bk_white  ">@isset($name) {{ $name }} @else 氏名不明 @endisset </td>
        <!-- 総支給額 ： CSVの３カラム目(C)のデータ-->
        <td class="w25 left   bk_primary">&nbsp;&nbsp;@isset($head[3]) {{ $head[3]  }} @endisset </td>
        <td class="w25 right  bk_white  ">@isset($data[3]) {{ $data[3]  }} @endisset </td>
      </tr>
    </table>
  </div>

  <div
   ><table border="0" width="100%" cellpadding="0" cellspacing="2">
      <tr>
      <td class="w50"
       ><table border="1" width="100%" cellpadding="2" cellspacint="0">
          <tr class="bk_primary font_M">
            <td class="w100 center font_M" colspan="2">支　給</td>
          </tr>
          <!-- 支給の明細部分 ： CSVの４カラム目(D)～１４カラム目(N)までのデータ -->
          @for ($i=4; $i<=14; $i++)
            @if ($i % 2 == 0)
              <tr class="bk_white font_S">
            @else
              <tr class="bk_table font_S">
            @endif
                <td class="w50 left ">&nbsp;@isset($head[$i]) {{ $head[$i] }} @endisset </td>
                <td class="w50 right">      @isset($data[$i]) {{ $data[$i] }} @endisset &nbsp;</td>
              </tr>
          @endfor

          <!-- 支給の合計部分 ： CSVの１５カラム目(O)のデータ-->
          <tr class="font_M">
            <td class="w50 left bk_primary">&nbsp;&nbsp;@isset($head[15]) {{ $head[15] }} @endisset </td>
            <td class="w50 right">                      @isset($data[15]) {{ $data[15] }} @endisset &nbsp;</td>
          </tr>
        </table>
      </td>

      <td class="w50"
       ><table border="1" width="100%" cellpadding="2" cellspacint="0">
          <tr class="bk_primary font_M">
            <td class="w100 center font_M" colspan="2">控　除</td>
          </tr>
          <!-- 控除の明細部分 ： CSVの１６カラム目(P)～２６カラム目(Z)までのデータ-->
          @for ($i=16; $i<=26; $i++)
            @if ($i % 2 == 0)
              <tr class="bk_white font_S">
            @else
              <tr class="bk_table font_S">
            @endif
                <td class="w50 left ">&nbsp;@isset($head[$i]) {{ $head[$i] }} @endisset </td>
                <td class="w50 right">      @isset($data[$i]) {{ $data[$i] }} @endisset &nbsp;</td>
              </tr>
          @endfor

          <!-- 控除の合計部分 ： CSVの２７カラム目(AA)のデータ-->
          <tr class="font_M">
            <td class="w50 left bk_primary">&nbsp;&nbsp; @isset($head[27]) {{ $head[27] }} @endisset </td>
            <td class="w50 right">                       @isset($data[27]) {{ $data[27] }} @endisset &nbsp;</td>
          </tr>
        </table>
      </td>
      </tr>
    </table>
  </div>
</body>
</html>

```


HTML4時代の感じで書くとよい感じ、というかHTML4な感じで書かないとダメです
TCPDF の制限で使えるタグやCSSがかなり制限されてますので昔ながらの？頑張ってTABLEでレイアウトを組む感じで仕上げます






# 給与明細PDF自動生成

Laravel側のPDF生成部分です
要求のあった社員の指定月の明細データをＤＢから取得してＰＤＦファイルを生成します


```php:app/Http/Controllers/PayslipController.php
～～～

use TCPDF;
use TCPDF_FONTS;

class PayslipController extends Controller
{
～～～
    public function pdf(Request $request)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $request->all());

        // INIT
        mb_internal_encoding("UTF-8");

        // データ確認 ＆ 取得 -- エラーがあれば処理終了
        $payslip = $this -> validate_pdf($request);
        if (is_object($payslip)) {
            Log::Debug(__CLASS__.':'.__FUNCTION__.' - ERROR - '. print_r($payslip, true));
            return $payslip;
        }

        // PDF 情報生成
        $data = $this -> make_pdf_data($payslip);

        // PDF 生成メイン　－　A4 縦に設定
        $pdf = new TCPDF("P", "mm", "A4", true, "UTF-8" );
        $pdf->setPrintHeader(false);
        $pdf->setPrintFooter(false);

        // PDF プロパティ設定
        $pdf->SetTitle('給与明細　'. $data['ym'] .'　'. $data['name']);       // PDFドキュメントのタイトルを設定
        $pdf->SetAuthor($data['company']);                                    // PDFドキュメントの著者名を設定
        $pdf->SetSubject($data['filename']);                                  // PDFドキュメントのサブジェクト(表題)を設定
        $pdf->SetKeywords('給与明細　'. $data['ym'] .'　'. $data['name']);    // PDFドキュメントのキーワードを設定
        $pdf->SetCreator($data['company'].'　u9m31');                         // PDFドキュメントの製作者名を設定

        // 日本語フォント設定
        $pdf->setFont('kozminproregular','',10);

        // ページ追加
        $pdf->addPage();

        // HTMLを描画、viewの指定と変数代入
        $html = view("m31_02", $data)->render();
        $pdf->writeHTML($html);

        // 出力指定 ファイル名、D(ダウンロード)
        $pdf->output('m31.pdf', 'D');
        return;
    }

    private function validate_pdf(Request $request)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $request->all());

        // INIT
        $req = $request -> all();
        $error_msg = '';

        // データを取得 User（操作者）
        $user = $request -> user();
        if (!$user) {
            return response() -> json(['errors' => [ 'request' => '12345  user not found']], 422);
        }
        Log::Debug(__CLASS__.':'.__FUNCTION__.' - user :: ', $user -> toArray());

        // データを取得 CSV Payslip  --  管理者の場合は論理削除済みでも取得可能
        if (array_key_exists('csv_id', $req)) {
            $query = CsvPayslip::query();
            if ($user -> can('admin')) { $query -> withTrashed(); }
            $csv_payslip = $query -> find($req['csv_id']);
            if (!$csv_payslip) {
                $error_msg = '12346 [csv_id] data  not found ';
            }
            else Log::Debug(__CLASS__.':'.__FUNCTION__.' - csv_payslip :: ', $csv_payslip -> toArray());
        } else {
            $error_msg = '12347 [csv_id] not found ';
        }

        // データを取得 Payslip  --  管理者の場合は論理削除済みでも取得可能
        if (array_key_exists('id', $req)) {
            $query = Payslip::query();
            if ($user -> can('admin')) { $query -> withTrashed(); }
            $payslip = $query -> find($req['id']);
            if (!$payslip) {
                $error_msg = '12348 [payslip id] data  not found ';
            }
            else Log::Debug(__CLASS__.':'.__FUNCTION__.' - payslip :: ', $payslip -> toArray());
        } else {
            $error_msg = '12349 [payslip id] not found ';
        }

        // ここまででエラーが発生していたら処理終了
        if ($error_msg) {
            return response() -> json(['errors' => [ 'request' => $error_msg]], 422);
        }

        // データの関連チェック payslipのcsv_id と csv_payslip の ID が違ったら、要求エラー
        if ($payslip -> csv_id != $csv_payslip -> id) {
            $error_msg = '12350 [payslip.csv_id] - [csv.id] mismatch';
            return response() -> json(['errors' => [ 'request' => $error_msg]], 422);
        }

        // 権限確認（一般ユーザは自分の明細以外はエラー）
        if (($user -> can('user')) && ($user -> id != $payslip -> user_id)) {
            return response() -> json(['errors' => [ 'auth' => '12351 対象の明細を開けませんでした']], 422);
        }

        // エラーなしリターン
        return array(
            'head' => $csv_payslip -> header,
            'data' => $payslip -> data,
            'name' => $payslip -> name,
            'file' => $payslip -> filename,
            'ym'   => $payslip -> ym,
        );
    }

    private function make_pdf_data($payslip)
    {
        Log::Debug(__CLASS__.':'.__FUNCTION__, $payslip);

        // INIT
        mb_language('ja');
        mb_internal_encoding("UTF-8");

        // 明細情報生成
        $ym = substr($payslip['ym'], 0, 4). '年' .substr($payslip['ym'], 4, 2) .'月度';
        $data['ym'] = mb_convert_kana($ym, 'N'); // - 全角数字に変換
        $data['name'] = $payslip['name'];
        $data['filename'] = $payslip['file'];
        $data['company'] = env('MIX_COMPANY_NAME', '環境変数 MIX_COMPANY_NAME を設定してください');

        // PDF埋め込み用 明細情報領域初期化
        $data['head'] = array_fill(0, count($payslip['head']), '');
        $data['data'] = array_fill(0, count($payslip['data']), '');

        // 明細項目名設定
        $cnt = 0;
        foreach($payslip['head'] as $v) {
            $data['head'][++$cnt] = $v;
        }

        // 明細項目データ設定 - もし項目が半角ハイフンだったらヘッダーを隠す
        $cnt = 0;
        foreach($payslip['data'] as $v) {
            $data['data'][++$cnt] = $v;
            if ($v == '-') {
                $data['head'][$cnt] = '';
                $data['data'][$cnt] = '';
            }
        }
        Log::Debug(__CLASS__.':'.__FUNCTION__.' - pdf data :: ', $data);

        // 生成結果を戻す
        return $data;
    }
}

```

最初に validate_pdf でエラーチェック
次にデータのPDF用整形を make_pdf_data で実行
最後にPDF作成処理という流れになってます

PDF作成部分は動作確認（STEP09）と同じ
Bladeの出力（データを当てはめたHTML）をTCPDF に渡してPDFにしてます



# ルート設定

新ルートを追加したのでルート設定
ついでにCSVファイルの管理と、明細の管理を分けたので整理もかねて全行表示

```php:routes/web.php
<?php

Route::get('/', function () {
    return view('home');
})->middleware('auth');

Route::get('/pdf', 'DocumentController@downloadPdf')->name('pdf');

// Authentication Routes...
Route::get('/login', 'Auth\LoginController@showLoginForm')->name('login');
Route::post('/login', 'Auth\LoginController@login');
Route::post('/logout', 'Auth\LoginController@logout')->name('logout');

// Admin
Route::group( ['middleware' => ['auth', 'can:admin']], function() {

  // USER
  Route::post('/api/admin/user', 'UserController@index')->name('admin/user');
  Route::post('/api/admin/user/store', 'UserController@store')->name('admin/user/store');
  Route::post('/api/admin/user/destroy', 'UserController@destroy')->name('admin/user/destroy');
  Route::post('/api/admin/user/download', 'UserController@download')->name('admin/user/download');
  Route::post('/api/admin/user/upload', 'UserController@upload')->name('admin/user/upload');

  // CsvPayslip
  Route::post('/api/admin/csvpayslip/index', 'CsvPayslipController@index')->name('admin/csvpayslip/index');
  Route::post('/api/admin/csvpayslip/upload', 'CsvPayslipController@upload')->name('admin/csvpayslip/upload');
  Route::post('/api/admin/csvpayslip/delete', 'CsvPayslipController@delete')->name('admin/csvpayslip/delete');
  Route::post('/api/admin/csvpayslip/publish', 'CsvPayslipController@publish')->name('admin/csvpayslip/publish');

  // Payslip
  Route::post('/api/admin/payslip/index', 'PayslipController@index')->name('admin/payslip/index');
  Route::post('/api/admin/payslip/delete', 'PayslipController@delete')->name('admin/payslip/delete');
  Route::post('/api/admin/payslip/pdf', 'PayslipController@pdf')->name('admin/payslip/pdf');

});

// Other
Route::get('/{any}', function () {
  return view('home');
})->middleware('auth')->where('any', '.*');

```

前回までは 「PayslipController」にすべて入れてましたが、ファイルを分けました
内容はほぼ変わらずにファイル名を変更したのみなので、詳細は github を参照ってことで




# 明細出力画面

Laravel側ができたのでVue側の出力画面を作成
前回までに作っていた画面構成は
　１段目：　明細CSV登録／明細検索　画面
　２段目：　明細CSV一覧画面
となっていたので、今回は
　３段目：　明細一覧画面（CSVの中の１行１行を一覧で表示）
を作ります
３段目にPDF出力用のボタンを配置して、ボタンを押したらブラウザでPDFをダウンロード、って動きにしときます

３段目まで追加するとファイルが大きくなってしまうので、段毎にコンポーネントを分けておきます

## １段目

分けたコンポーネントを呼び出す部分と、親子コンポーネントの連携を追加

![z33.png](https://qiita-image-store.s3.amazonaws.com/0/153259/69a81f72-cfdc-236e-c6ee-07f5f5da40d1.png)


```html:resources/js/components/Admin/PayslipComponent.vue
<template>
  <v-flex>
    <!-- １段目 ：検索？登録？ -->
    <v-card xs12 class="m-3 px-3" v-show="!csvList">
      <v-card-title class="title">
        <v-icon class="pr-2">{{ $route.meta.icon }}</v-icon> {{ $route.meta.name }} {{ /* 給与明細 */ }}
        【{{ (searchTab ? '検索' : '登録') }}】
        <v-spacer></v-spacer>
        <v-btn block depressed
          :outline="searchTab"
          :color="searchTab ? 'primary' : 'blue-grey lighten-5'"
          @click="searchTab = true"
        >
          <v-icon class="pr-1">search</v-icon>検索
        </v-btn>
        <v-btn block depressed
          :outline="!searchTab"
          :color="!searchTab ? 'primary' : 'blue-grey lighten-5'"
          @click="searchTab = false"
        >
          <v-icon class="pr-1">cloud_upload</v-icon>登録
        </v-btn>
      </v-card-title>

      <v-layout row wrap class="mx-3 my-2">
        <!-- 共通： 対象年月（カレンダーで月を選択）-->
        <v-flex xs2 md4 lg3>
          <v-menu
            ref="menu"
            v-model="menu"
            :return-value.sync="target.ym"
            :close-on-content-click="false"
            :nudge-right="20"
            lazy transition="scale-transition" offset-y full-width max-width="290px" min-width="290px"
            show-current="true"
          >
            <v-text-field
              readonly
              clearable
              autofocus
              slot="activator"
              v-model="target.ym"
              label="対象年月"
              placeholder="明細の対象年月を選択"
              :hint="'明細の対象年月を選択' + (searchTab?'(指定なしで全期間対象)':'')"
            ></v-text-field>
            <v-date-picker v-model="target.ym" type="month" no-title scrollable locale="ja">
              <v-spacer></v-spacer>
              <v-btn flat color="primary" @click="menu = false">Cancel</v-btn>
              <v-btn flat color="primary" @click="$refs.menu.save(target.ym)">OK</v-btn>
            </v-date-picker>
          </v-menu>
        </v-flex>

        <!-- 検索： 削除済みデータも検索に含める -->
        <v-flex xs5 md4 lg3 v-if="searchTab">
          <v-switch
            :label="`削除済データ ${target.deleted ? '対象' : '対象外'}`"
            v-model="target.deleted"
            color="error"
            class="px-2"
            hint="削除済みデータも検索したい場合に指定"
          ></v-switch>
        </v-flex>

      </v-layout>

      <v-card-actions class="pb-2">
        <!-- 登録ボタン -->
        <csv-upload
          v-show="!searchTab"
          :updata="target"
          color="primary"
          icon="cloud_upload"
          @reload="reload"
          @axios-logout="$emit('axios-logout')"
          url="/api/admin/csvpayslip/upload"
        ></csv-upload>

        <!-- 検索ボタン -->
        <v-btn block flat
          v-show="searchTab"
          @click="showList()"
          color="primary"
        >
          <v-icon class="pr-2">search</v-icon>検索
        </v-btn>
      </v-card-actions>
    </v-card>

    <!-- ２段目 ：CSVリスト表示 -->
    <csv-payslip
      v-show="csvList"
      ref="csvPayslip"
      :target="target"
      :detail_id="detail_id"
      @axios-logout="$emit('axios-logout')"
      @closeList="closeList"
      @showDetail="showDetail"
    ></csv-payslip>

    <!-- ３段目 ：明細リスト表示 -->
    <payslip-detail
      v-show="detailList"
      ref="detailPayslip"
      :target="target"
      @axios-logout="$emit('axios-logout')"
      @closeDetail="closeDetail()"
    ></payslip-detail>
  </v-flex>
</template>

<script>
  import csv_upload from './CsvUpload.vue'
  import csv_payslip from './CsvPayslipComponent.vue'
  import payslip_detail from './PayslipDetailComponent.vue'

  export default {
    name: 'PayslipComponent',

    components: {
      'csv-upload': csv_upload,
      'csv-payslip': csv_payslip,
      'payslip-detail': payslip_detail,
    },

    props: {
    },

    data: () => ({
      menu: false,
      searchTab: true,
      csvList: false,
      detailList: false,
      detail_id: 0,
      target: {
        ym: '',
        deleted: false,
      },
    }),

    created() {
      if (process.env.MIX_DEBUG) console.log('Payslip Component created.')
      this.initialize()
    },

    methods: {
      initialize() {
        // 初期表示時は現在年月を設定しておく
        this.target.ym = moment().format('YYYY-MM').toString()
      },

      reload() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component reload')
        this.showList()
      },

      showList() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component SHOW List')
        this.csvList = true
        this.$refs.csvPayslip.getCsvPayslip()
      },

      closeList() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component CLOSE List')
        this.csvList = false
        this.closeDetail()
      },

      showDetail(item) {
        if (process.env.MIX_DEBUG) console.log('Payslip Component SHOW Detail')
        if (this.detail_id == item.id) {
            this.closeDetail()
        }
        else {
          this.detailList = true
          this.detail_id = item.id
          this.$refs.detailPayslip.getPayslipDetail(item)
        }
      },

      closeDetail() {
        if (process.env.MIX_DEBUG) console.log('Payslip Component CLOSE Detail')
        this.detailList = false
        this.detail_id = 0
      },
    },
  }
</script>

```


## ２段目

CSVの一覧から明細データを呼び出す部分を追加


![z34.png](https://qiita-image-store.s3.amazonaws.com/0/153259/eb6c7414-605f-7878-4efc-954a5b1b6277.png)



```html:resources/js/components/Admin/CsvPayslipComponent.vue

<template>
  <v-flex>
    <!-- ２段目 ：CSVリスト表示 -->
    <v-card xs12 class="m-3 px-3">
      <v-card-title class="title">
        <v-icon class="pr-2" @click="closeList">{{ $route.meta.icon }}</v-icon> {{ csvTitle }} {{ /* 給与明細ＣＳＶ検索 */ }}
        <v-spacer></v-spacer>
        <v-spacer></v-spacer>
        <v-text-field
          v-model="search"
          prepend-icon="search"
          label="絞り込み表示"
          single-line
          hide-details
          clearable
        ></v-text-field>
        <v-icon @click="closeList" class="accent ml-5" dark>close</v-icon>
      </v-card-title>

      <v-data-table
        :headers="headers"
        :items="tabledata"
        :pagination.sync="pagination"
        :rows-per-page-items='[5,10,20,{"text":"All","value":-1}]'
        :loading="loading"
        :search="search"
        class="elevation-0 p-1"
      >
        <v-progress-linear slot="progress" color="blue" indeterminate></v-progress-linear>

        <template slot="items" slot-scope="props">
          <tr :class="{'pink--text': (props.item.deleted_at != null)}" v-show="props.item.id == detail_id || detail_id == 0">
            <td class="text-xs-center" xs1>{{ (props.index + 1) + (pagination.page - 1) * pagination.rowsPerPage }}</td>
            <template v-for="n in (headers.length - 1)">

              <td v-if="headers[n].text != 'アクション'"
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
                v-text="props.item[headers[n].value]"
              ></td>

              <td v-else
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
              >
                <v-tooltip right :color="(props.item.deleted_at == null) ? 'primary': 'grey'">
                  <v-btn fab small flat @click="$emit('showDetail', props.item)" slot="activator">
                    <v-icon color="primary">list</v-icon>
                  </v-btn>
                  <span>明細一覧</span>
                </v-tooltip>

                <v-tooltip v-if="props.item.published_at == null" right :color="(props.item.deleted_at == null) ? 'success' : 'grey'">
                  <v-btn fab small  flat @click="dialog_open(props.item, 'pub')" slot="activator"
                    :disabled="props.item.deleted_at != null"
                  >
                    <v-icon color="success">lock</v-icon>
                  </v-btn>
                  <span>公開</span>
                </v-tooltip>
                <v-btn v-else fab small flat disabled>
                  <v-icon color="grey lighten-1">lock_open</v-icon>
                </v-btn>

                <v-tooltip right :color="(props.item.deleted_at == null) ? 'error': 'grey'">
                  <v-btn fab small flat @click="dialog_open(props.item, 'del')" slot="activator"
                    :disabled="props.item.deleted_at != null">
                    <v-icon color="error">delete</v-icon>
                  </v-btn>
                  <span>{{(props.item.deleted_at == null ? '削除' : '削除済')}}</span>
                </v-tooltip>
            </td>
            </template>
          </tr>
        </template>
      </v-data-table>
    </v-card>

    <!-- 確認ダイアログ -->
    <v-dialog v-model="dialog" width="500" persistent>
      <v-card>
        <v-toolbar :color="d.titlecolor" dark>
          <v-toolbar-title>{{ d.title }}</v-toolbar-title>
        </v-toolbar>
        <v-card-text class="subheading">
          <span v-html="d.body"></span>
          <br>よろしいですか？
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-btn flat block @click="dialog_no()" > いいえ </v-btn>
          <v-spacer></v-spacer>
          <v-btn flat block @click="dialog_yes()" :color="d.titlecolor"> はい </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

  </v-flex>
</template>

<script>

  export default {
    name: 'CsvPayslipComponent',

    components: {
    },

    props: {
      target: Object,
      detail_id: Number,
    },

    data: () => ({
      loading: false,
      search: '',
      pagination: { sortBy: null, descending: false, },

      // csv list
      csvTitle: '',
      tabledata: [],
      headers: [
        { align: 'center', sortable: false, text: 'No', },
        { align: 'center', sortable: true,  text: '年月',       value: 'ym' },
        { align: 'center', sortable: false, text: 'アクション', },
        { align: 'center', sortable: true,  text: '状態',       value: 'status' },
        { align: 'center', sortable: true,  text: 'CSV-ID',     value: 'id' },
        { align: 'center', sortable: true,  text: '対象者数',   value: 'line' },
        { align: 'center', sortable: true,  text: 'エラー数',   value: 'error' },
        { align: 'left',   sortable: true,  text: 'ファイル名', value: 'filename' },
        { align: 'left',   sortable: true,  text: '登録日時',   value: 'created_at' },
        { align: 'left',   sortable: true,  text: '公開日時',   value: 'published_at' },
        { align: 'left',   sortable: true,  text: '削除日時',   value: 'deleted_at' },
      ],

      // dialog
      dialog: false,
      d: {
        title: '',
        titlecolor: '',
        icon: '',
        type: '',
        item: [],
      },

    }),

    created() {
      if (process.env.MIX_DEBUG) console.log('CsvPayslip Component created.')
      this.initialize()
    },

    methods: {
      initialize() {
      },

      reload() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component reload')
        this.getCsvPayslip()
      },

      initList() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component initList')
        this.setCsvTitle()
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
      },

      closeList() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component closeList')
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
        this.$emit('closeList')
      },

      setCsvTitle() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component set csv title')
        this.csvTitle = ''
        if (this.target.ym) this.csvTitle += this.target.ym + '　'
        else this.csvTitle += '全期間　'
      },

      // 登録済みCSVのリストをサーバから取得する
      getCsvPayslip() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component getCsvPayslip')

        // 初期化
        this.initList()

        // 検索パラメータ設定
        var params = new URLSearchParams()
        params.append('ym', (this.target.ym ? this.target.ym : ''))
        params.append('deleted', (this.target.deleted ? this.target.deleted : false))

        // 検索要求
        this.loading = true
        axios.post('/api/admin/csvpayslip/index', params)

        // 検索結果［正常］
        .then( function (response) {
          this.loading = false
          if (process.env.MIX_DEBUG) console.log(response)
          if (response.data.data) {
            this.tabledata = response.data.data
            this.setStatus()
          }
        }.bind(this))

        // 検索結果［異常］
        .catch(function (error) {
          this.loading = false
          console.log(error)
          if (error.response) {
            if ([401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },

      setStatus() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component set status')
        var wk = '不明'
        for (var i=0; i<this.tabledata.length; i++) {
          if (this.tabledata[i].status) {
            wk = '不明'
            if (this.tabledata[i].status == 0) { wk = '非公開' }
            else if (this.tabledata[i].status == 1) { wk = '公開' }
            if (this.tabledata[i].deleted_at != null ) { wk = '削除' }
            this.tabledata[i].status = wk
          }
        }
      },

      dialog_open(item, type) {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component Dialog Open')
        this.d.type = type
        this.d.item = item
        this.d.body = 'CSV ID：' + item.id + '<br>' + '対象年月：' + item.ym + '<br>' + 'ファイル：' + item.filename + '<br><br>'
        if (type == 'pub') {
          this.d.title = '明細情報を公開します'
          this.d.titlecolor = 'success'
          this.d.body += '対象のデータを公開します。<br>公開後は非公開とすることはできません。'
        }
        else {
          this.d.title = '明細情報を削除します'
          this.d.titlecolor = 'error'
          this.d.body += '対象のデータを削除します。'
        }
        this.dialog = true
      },

      dialog_yes() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component Dialog YES')
        this.dialog = false
        var url = '/api/admin/csvpayslip/publish'
        if (this.d.type == 'del') { url = '/api/admin/csvpayslip/delete' }
        this.csvUpdate(url, this.d.item.id)
        this.d.type = ''
        this.d.item = []
      },

      dialog_no() {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component Dialog NO')
        this.dialog = false
        this.d.type = ''
        this.d.item = []
      },

      // CSVの状態を更新
      csvUpdate(url, id) {
        if (process.env.MIX_DEBUG) console.log('CsvPayslip Component CSV Update')

        // パラメータ設定
        var params = new URLSearchParams()
        params.append('id', id)

        // 更新要求
        axios.post(url, params)

        // 更新結果［正常］
        .then( function (response) {
          if (process.env.MIX_DEBUG) console.log(response)
          this.reload()
        }.bind(this))

        // 更新結果［異常］
        .catch(function (error) {
          console.log(error)
          if (error.response) {
            if ([401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },
    },
  }
</script>

```

うーん
VUE ファイルは処理単位でまとまってて分かりやすいんだけど。。長いな。。。



## ３段目

新規作成

![z35.png](https://qiita-image-store.s3.amazonaws.com/0/153259/603a1782-40d5-f1b3-c29a-5dcefb35dfb3.png)


PDFをダウンロードする処理を埋め込んでます
PDFのダウンロードのため ResponseType が BLOB なので、エラー時のメッセージ取り出しがめんどくさいことになってます

PDF のファイル名はVueでつけてます

```bash:PDFファイル名はこんな感じ

　'給与明細'_'年月'_'氏名''(補助ファイル名)'_'出力日時'.pdf

例1：給与明細_201901_メイプル管理者_20190102_010203.pdf
例2：給与明細_201901_メイプル管理者(補正１)_20190102_010203.pdf

```

CSVの２カラム目にファイル名を指定すると、氏名の後ろに追加するようにしてます


```html:resources/js/components/Admin/PayslipDetailComponent.vue

<template>
  <v-flex>
    <!-- ３段目 ：明細詳細表示 -->
    <v-card xs12 class="m-3 px-3">
      <v-card-title class="title">
        <v-icon class="pr-2" @click="closeDetail">{{ $route.meta.icon }}</v-icon> {{ title }} {{ /* 給与明細詳細検索 */ }}
        <v-spacer></v-spacer>
        <v-spacer></v-spacer>
        <v-text-field
          v-model="search"
          prepend-icon="search"
          label="絞り込み表示"
          single-line
          hide-details
          clearable
        ></v-text-field>
        <v-icon @click="closeDetail" class="accent ml-5" dark>close</v-icon>
      </v-card-title>

      <v-data-table
        :headers="headers"
        :items="tabledata"
        :pagination.sync="pagination"
        :rows-per-page-items='[10,25,50,{"text":"All","value":-1}]'
        :loading="loading"
        :search="search"
        class="elevation-0 p-1"
      >
        <v-progress-linear slot="progress" color="blue" indeterminate></v-progress-linear>

        <template slot="items" slot-scope="props">
          <tr :class="{'pink--text': (props.item.deleted_at != null)}">
            <td class="text-xs-center" xs1>{{ (props.index + 1) + (pagination.page - 1) * pagination.rowsPerPage }}</td>
            <template v-for="n in (headers.length - 1)">

              <td v-if="headers[n].text != 'アクション'"
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
                v-text="props.item[headers[n].value]"
              ></td>

              <td v-else
                :class="'text-xs-' + headers[n].align"
                style="white-space: nowrap;"
              >
                <v-tooltip right :color="(csv_item.published_at != null) ? 'success': 'grey'">
                  <v-btn fab small  flat @click="pdf_download(props.item)" slot="activator"
                    :disabled="props.item.error != null"
                  >
                    <v-icon :color="(csv_item.published_at != null) ? 'success' : 'grey'">description</v-icon>
                  </v-btn>
                  <span>明細PDF</span>
                </v-tooltip>

                <v-tooltip right :color="(props.item.deleted_at == null) ? 'error': 'grey'">
                  <v-btn fab small flat @click="dialog_open(props.item, 'del')" slot="activator"
                    :disabled="props.item.deleted_at != null || csv_item.deleted_at != null"
                  >
                    <v-icon color="error">delete</v-icon>
                  </v-btn>
                  <span>{{(props.item.deleted_at == null ? '削除' : '削除済')}}</span>
                </v-tooltip>
            </td>
            </template>
          </tr>
        </template>
      </v-data-table>
    </v-card>

    <!-- 確認ダイアログ -->
    <v-dialog v-model="dialog" width="500" persistent>
      <v-card>
        <v-toolbar :color="d.titlecolor" dark>
          <v-toolbar-title>{{ d.title }}</v-toolbar-title>
        </v-toolbar>
        <v-card-text class="subheading">
          <span v-html="d.body"></span>
          <br>よろしいですか？
        </v-card-text>
        <v-divider></v-divider>
        <v-card-actions>
          <v-btn flat block @click="dialog_no()" > いいえ </v-btn>
          <v-spacer></v-spacer>
          <v-btn flat block @click="dialog_yes()" :color="d.titlecolor"> はい </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

  </v-flex>
</template>

<script>

  export default {
    name: 'PayslipDetailComponent',

    components: {
    },

    props: {
      target: Object,
    },

    data: () => ({
      loading: false,
      search: '',
      pagination: { sortBy: '', descending: false, },

      // detail list
      title: '',
      csv_item: {},
      tabledata: [],
      headers: [
        { align: 'center', sortable: false, text: 'No', },
        { align: 'center', sortable: false, text: 'アクション', },
        { align: 'center', sortable: true,  text: 'CSV行',      value: 'line' },
        { align: 'left',   sortable: true,  text: '氏名',       value: 'name' },
        { align: 'left',   sortable: true,  text: 'ログインID', value: 'loginid' },
        { align: 'center', sortable: true,  text: 'DL回数',     value: 'download' },
        { align: 'left',   sortable: true,  text: 'ファイル名', value: 'filename' },
        { align: 'left',   sortable: true,  text: 'CSVエラー',  value: 'error' },
        { align: 'left',   sortable: true,  text: '削除日時',   value: 'deleted_at' },
        { align: 'left',   sortable: true,  text: '削除者ID',   value: 'delete_user_id' },

        { align: 'center', sortable: true,  text: 'CSV-ID',     value: 'csv_id' },
        { align: 'left',   sortable: true,  text: '状態',       value: 'status' },
        { align: 'center', sortable: true,  text: '年月',       value: 'ym' },
        { align: 'center', sortable: true,  text: 'ユーザID',   value: 'user_id' },
      ],

      // dialog
      dialog: false,
      d: {
        title: '',
        titlecolor: '',
        icon: '',
        item: [],
      },

    }),

    created() {
      if (process.env.MIX_DEBUG) console.log('PayslipDetail Component created.')
      this.initialize()
    },

    methods: {
      initialize() {
      },

      reload() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component reload')
        this.getPayslipDetail()
      },

      initList() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component initList')
        this.setTitle()
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
      },

      closeDetail() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component closeDetail')
        this.tabledata = []
        this.search = ''
        this.pagination.sortBy = ''
        this.pagination.descending = false
        this.$emit('closeDetail')
      },

      setTitle() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component set title')
        this.title = ''
        this.title = 'ID: ' + this.csv_item.id + '  - ' + this.csv_item.ym
        if (this.csv_item.deleted_at != null) {
          this.title += ' - 削除済'
        }
        else if (this.csv_item.published_at != null) {
          this.title += ' - 公開済'
        }
        else {
          this.title += ' - 未公開'
        }
      },

      // 指定の明細リストサーバから取得する
      getPayslipDetail(item) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component getPaslipDetail')

        // 初期化
        this.csv_item = (item ? item : this.csv_item)
        this.initList()

        // 検索パラメータ設定
        var params = new URLSearchParams()
        params.append('id', this.csv_item.id)

        // 検索要求
        this.loading = true
        axios.post('/api/admin/payslip/index', params)

        // 検索結果［正常］
        .then( function (response) {
          this.loading = false
          if (process.env.MIX_DEBUG) console.log(response)
          if (response.data.data) {
            this.tabledata = response.data.data
            this.setStatus()
          }
        }.bind(this))

        // 検索結果［異常］
        .catch(function (error) {
          this.loading = false
          console.log(error)
          if (error.response) {
            if ([401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },

      setStatus() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component set status')
        var wk = '不明'
        for (var i=0; i<this.tabledata.length; i++) {
          if (this.tabledata[i].status) {
            wk = '不明'
            if (this.tabledata[i].status == 0) { wk = '有効' }
            else if (this.tabledata[i].status == 9) { wk = '削除' }
            if (this.tabledata[i].deleted_at != null ) { wk = '削除' }
            this.tabledata[i].status = wk
          }
        }
      },

      dialog_open(item) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Dialog Open')
        this.d.item = item
        this.d.body = 'CSV行：' + item.line + '<br>' +
                      '対象年月：' + item.ym + '<br>' +
                      '対象者：' + item.name + '<br>' +
                      'ログインID：' + item.loginid + '<br>' +
                      'ダウンロード回数：' + item.download + ' 回<br>' +
                      '<br>'
        this.d.title = '明細情報を削除します'
        this.d.titlecolor = 'error'
        this.d.body += '対象のデータを削除します。'
        if (item.download > 0) {
          this.d.body += '<br> 利用者は明細をダウンロードしたことがあるようです。'
        }
        this.dialog = true
      },

      dialog_yes() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Dialog YES')
        this.dialog = false
        var url = '/api/admin/payslip/delete'
        this.detailUpdate(url, this.d.item.id)
        this.d.item = []
      },

      dialog_no() {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Dialog NO')
        this.dialog = false
        this.d.item = []
      },

      // 明細の状態を更新
      detailUpdate(url, id) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component Detail Update')

        // パラメータ設定
        var params = new URLSearchParams()
        params.append('id', id)

        // 更新要求
        axios.post(url, params)

        // 更新結果［正常］
        .then( function (response) {
          if (process.env.MIX_DEBUG) console.log(response)
          this.reload()
        }.bind(this))

        // 更新結果［異常］
        .catch(function (error) {
          console.log(error)
          if (error.response) {
            if ([401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },

      pdf_download(item) {
        if (process.env.MIX_DEBUG) console.log('PayslipDetail Component PDF Download')

        // パラメータ設定
        var params = new URLSearchParams()
        params.append('loginid', item.loginid)  // 操作ログ記録用
        params.append('name',    item.name)     // 操作ログ記録用
        params.append('yyyymm',  item.ym)       // 操作ログ記録用
        params.append('csv_id',  item.csv_id)
        params.append('id',  item.id)
        var config = {
          responseType: 'blob',
        }

        // ダウンロード要求
        this.loading = true
        axios.post('/api/admin/payslip/pdf', params, config)

        // 正常
        .then( function (response) {
          this.loading = false
          if (response.data) {
            // PDFデータ取得
            var blob = new Blob([response.data], { "type" : "application/pdf" })

            // 補助ファイル名設定
            var f = ''
            if (item.filename != null) {
              f = '('+ item.filename +')'
            }

            // ファイル名設定 - '給与明細'_'年月'_'氏名''(補助ファイル名)'_'出力日時'.pdf
            // 　　　　　　　 - 氏名からは「全角空白」、「半角空白」、「スラッシュ」を削除しとく
            var filename = '給与明細'
            filename += '_' + item.ym
            filename += '_'+ item.name.replace(/　/g,'').replace(/ /g,'').replace(/\//g,'')
            filename += f
            filename += '_' + moment(Date.now()).format("YYYYMMDD_HHmmss")
            filename += '.pdf'

            // ＰＤＦダウンロード（表示）　IE11
            if (window.navigator.msSaveBlob) {
              window.navigator.msSaveBlob(blob, filename)
              window.navigator.msSaveOrOpenBlob(blob, filename)
            }

            // ＰＤＦダウンロード　Chrome, Safari, Firefox, etc...
            else {
              const url = window.URL.createObjectURL(blob)
              const link = document.createElement('a')
              link.href = url
              link.setAttribute('download', filename)
              document.body.appendChild(link)
              link.click()
              link.remove()
            }
          }
          else {
            alert('PDF ダウンロードエラー')
          }
        }.bind(this))

        // ダウンロード失敗
        .catch(function (error) {
          this.loading = false
          console.log(error)
          if (error.response) {
            if ([401, 419].includes(error.response.status)) {
              this.$emit('axios-logout')
            }
            else if ([422].includes(error.response.status)) {
              if (process.env.MIX_DEBUG) console.log("PDF Download error 422")
              if (process.env.MIX_DEBUG) console.log(error.response.data)

              // error message blog to json - 通信時に BLOB 指定しているのでエラーメッセージもBLOB.. JSONに戻して取り扱い
              //                                     - responseType: 'blob'
              var reader = new FileReader()
              reader.onload = function(e) { // 非同期処理のため
                var data = JSON.parse(reader.result)
                if (process.env.MIX_DEBUG) console.log(data)
                if (data.errors) {
                  for (let key in data.errors) {
                    if (data.errors[key]) {
                      alert(key + ' : ' + data.errors[key])
                    }
                  }
                }
              }
              reader.readAsText(error.response.data, "UTF-8")
            }
            else {
              alert('ERROR ' + error.response.status + ' ' + error.response.statusText)
            }
          }
          else {
            alert('ERROR ' + error)
          }
        }.bind(this))
      },

    },
  }
</script>

```

# サンプルCSV

明細用のサンプルCSVです
このデータをアップロードして表示してます

```csv
loginID,file,総支給額,基本給,交通費,No6-F,No7-G,No8-H,No9-I,No10-J,No11-K,No12-L,No13-M,No14-N,支給額合計,雇用保険料,健康保険料,厚生年金保険料,No19-S,No20-T,No21-U,No22-V,No23-W,No24-X,No25-Y,No26-Z,控除額合計
maple_admin,補正２,"1,000","2,000","3,000",0,1,2,3,4,-,0,1,-,"4,000","5,000","6,000","7,000",-,0,1,1.1,2.2,-,3,-,"8,000"
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27

```

No～ のアルファベットは Excelの A1,B1 などの列アルファベットに対応
作る時の目安にでもしてくださいです

データ行で "-"（半角ハイフン）だけの項目は、PDFから項目名の表示を消すことができるようにしてあります
特定の人以外には表示したくない場合に指定してください

表示を消しても行を「詰める」ことはしないです
ただの空行として表示します



# 動作確認

いろいろいじっているのでコンパイルしてから、laravelを起動

```bash

npm run dev
php artisan serve --host=172.16.0.100  --port=8000

```

　※ IPアドレスやポートは任意ですよ


ブラウザでアクセスして
http://172.16.0.100:8000/

給与明細ページを開いて、CSVを登録して、PDF表示ボタンを押すと


![z38.png](https://qiita-image-store.s3.amazonaws.com/0/153259/9aacf888-b020-acfc-ef4f-5799e973dfb1.png)


ちゃんとPDFがダウンロードされること


![z36.png](https://qiita-image-store.s3.amazonaws.com/0/153259/84737599-22b0-c19f-ad4a-b9d28c478622.png)


ダウンロードされたPDFは表示できること

PDFのプロパティも設定されていること

![z37.png](https://qiita-image-store.s3.amazonaws.com/0/153259/65af7be4-68f1-3eab-44ae-0bd0480f0cf6.png)



---

以上
給与明細のPDFファイルを生成できました



今回の記事ではブラウザ上でのデータ内容の更新には対応してませんので、CSVが全てです
この記事を参考にして給与明細の電子交付を実際に行う場合には、CSVファイルの内容について税理士さんに確認してもらったりデータチェックは確実に行ってからアップロードしてください





今回もソースはこちら
https://github.com/u9m31/u9m31/tree/step13