diff --git a/app/Console/Commands/BackupExport.php b/app/Console/Commands/BackupExport.php index 0697982..095b038 100644 --- a/app/Console/Commands/BackupExport.php +++ b/app/Console/Commands/BackupExport.php @@ -6,6 +6,8 @@ use App\Models\CustomerSignature; use App\Models\Quote; use App\Models\SenderProfile; +use App\Models\Settlement; +use App\Models\SettlementSchedule; use App\Services\BackupAudit; use App\Services\BackupRestorer; use Illuminate\Console\Command; @@ -27,6 +29,8 @@ public function handle(BackupAudit $audit): int 'customers' => Customer::all()->toArray(), 'customer_signatures' => CustomerSignature::all()->toArray(), 'quotes' => Quote::all()->toArray(), + 'settlements' => Settlement::all()->toArray(), + 'settlement_schedules' => SettlementSchedule::all()->toArray(), ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); $output = $this->option('output'); diff --git a/app/Http/Controllers/BackupController.php b/app/Http/Controllers/BackupController.php index 0087400..c2afd91 100644 --- a/app/Http/Controllers/BackupController.php +++ b/app/Http/Controllers/BackupController.php @@ -8,6 +8,8 @@ use App\Models\CustomerSignature; use App\Models\Quote; use App\Models\SenderProfile; +use App\Models\Settlement; +use App\Models\SettlementSchedule; use App\Services\BackupRestorer; use App\Services\BackupStorage; use Illuminate\Http\JsonResponse; @@ -84,6 +86,8 @@ public function download(): JsonResponse 'customers' => Customer::all()->toArray(), 'customer_signatures' => CustomerSignature::all()->toArray(), 'quotes' => Quote::all()->toArray(), + 'settlements' => Settlement::all()->toArray(), + 'settlement_schedules' => SettlementSchedule::all()->toArray(), ]); } diff --git a/app/Http/Controllers/SettlementController.php b/app/Http/Controllers/SettlementController.php new file mode 100644 index 0000000..fe6836b --- /dev/null +++ b/app/Http/Controllers/SettlementController.php @@ -0,0 +1,131 @@ +orderByDesc('start_date') + ->orderByDesc('id') + ->get(); + } + + public function store(Request $request) + { + $data = SettlementPricing::apply($this->validated($request)); + + $settlement = DB::transaction(function () use ($data) { + $settlement = Settlement::create($data); + // interval_count 等の DB default を attributes に反映してから各回を生成する。 + $settlement->refresh(); + // 新規作成時は過去/未来の区別なく全回を予定データとして持つ。 + $settlement->settlementSchedules()->createMany( + SettlementScheduleGenerator::generate($settlement), + ); + + return $settlement; + }); + + return response()->json($settlement->load('settlementSchedules'), 201); + } + + public function show(Settlement $settlement) + { + return $settlement->load('settlementSchedules'); + } + + public function update(Request $request, Settlement $settlement) + { + $data = SettlementPricing::apply(array_merge( + $settlement->only(['items', 'tax_rate']), + $this->validated($request), + )); + + DB::transaction(function () use ($settlement, $data) { + $settlement->update($data); + $settlement->refresh(); + // 過去回のスナップショットは不変。基準日(今日)以降の回だけ現在条件で作り直す。 + SettlementScheduleGenerator::sync($settlement); + }); + + return $settlement->fresh()->load('settlementSchedules'); + } + + public function destroy(Settlement $settlement) + { + $settlement->delete(); + + return response()->noContent(); + } + + private function validated(Request $request): array + { + return $request->validate([ + // settlement_number は quote_number と同じ扱い(nullable + UNIQUE)。 + // 未設定(null)は対象外=番号なし下書きは複数作成可能。 + 'settlement_number' => [ + 'nullable', 'string', 'max:100', + Rule::unique('settlements', 'settlement_number')->ignore($request->route('settlement')), + ], + 'subject' => ['nullable', 'string', 'max:255'], + // status/interval_count/billing_timing/tax_rate は DB 側が NOT NULL + default。 + // nullable だと明示的な null が DB エラー(500)になるため、未指定は default に + // 任せつつ null は 422 で弾くために sometimes を使う(値が来た時だけ検証する)。 + 'status' => ['sometimes', 'in:draft,active,ended'], + + // 終了条件は必須: end_date か occurrences_count のいずれか(無期限は不可)。 + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'required_without:occurrences_count', 'date', 'after_or_equal:start_date'], + 'occurrences_count' => ['nullable', 'required_without:end_date', 'integer', 'min:1'], + 'interval_unit' => ['required', 'in:month,year'], + 'interval_count' => ['sometimes', 'integer', 'min:1'], + 'billing_day' => ['nullable', 'integer', 'between:1,31'], + 'billing_timing' => ['sometimes', 'in:advance,arrears'], + + 'payment_terms' => ['nullable', 'string', 'max:255'], + 'tax_rate' => ['sometimes', 'integer', 'min:0', 'max:100'], + 'notes' => ['nullable', 'string'], + + 'sender_profile_id' => ['nullable', 'integer'], + 'sender_company' => ['nullable', 'string', 'max:255'], + 'sender_zip' => ['nullable', 'string', 'max:20'], + 'sender_pref' => ['nullable', 'string', 'max:50'], + 'sender_city' => ['nullable', 'string', 'max:255'], + 'sender_address1' => ['nullable', 'string', 'max:255'], + 'sender_address2' => ['nullable', 'string', 'max:255'], + 'sender_person' => ['nullable', 'string', 'max:255'], + 'sender_tel' => ['nullable', 'string', 'max:50'], + 'sender_fax' => ['nullable', 'string', 'max:50'], + 'sender_logo_url' => ['nullable', 'string'], + + 'customer_id' => ['nullable', 'integer'], + 'customer_name' => ['nullable', 'string', 'max:255'], + 'customer_department' => ['nullable', 'string', 'max:255'], + 'customer_person' => ['nullable', 'string', 'max:255'], + 'customer_zip' => ['nullable', 'string', 'max:20'], + 'customer_pref' => ['nullable', 'string', 'max:50'], + 'customer_city' => ['nullable', 'string', 'max:255'], + 'customer_address1' => ['nullable', 'string', 'max:255'], + 'customer_address2' => ['nullable', 'string', 'max:255'], + 'customer_tel' => ['nullable', 'string', 'max:50'], + + 'items' => ['nullable', 'array'], + 'items.*.name' => ['nullable', 'string', 'max:255'], + 'items.*.spec' => ['nullable', 'string', 'max:255'], + 'items.*.quantity' => ['nullable', 'numeric'], + 'items.*.unit' => ['nullable', 'string', 'max:50'], + 'items.*.standard_price' => ['nullable', 'numeric'], + 'items.*.unit_price' => ['nullable', 'numeric'], + 'items.*.total' => ['nullable', 'numeric'], + ]); + } +} diff --git a/app/Http/Controllers/SettlementScheduleController.php b/app/Http/Controllers/SettlementScheduleController.php new file mode 100644 index 0000000..30ff1a9 --- /dev/null +++ b/app/Http/Controllers/SettlementScheduleController.php @@ -0,0 +1,31 @@ +validate([ + 'settlement_id' => ['nullable', 'integer'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date', 'after_or_equal:from'], + ]); + + return SettlementSchedule::query() + ->when(isset($filters['settlement_id']), fn ($query) => $query->where('settlement_id', $filters['settlement_id'])) + ->when($filters['from'] ?? null, fn ($query, $from) => $query->whereDate('billing_date', '>=', $from)) + ->when($filters['to'] ?? null, fn ($query, $to) => $query->whereDate('billing_date', '<=', $to)) + ->orderBy('billing_date') + ->orderBy('settlement_id') + ->orderBy('sequence_no') + ->get(); + } +} diff --git a/app/Models/Settlement.php b/app/Models/Settlement.php new file mode 100644 index 0000000..a4ac36a --- /dev/null +++ b/app/Models/Settlement.php @@ -0,0 +1,29 @@ + 'array', + 'start_date' => 'date:Y-m-d', + 'end_date' => 'date:Y-m-d', + 'occurrences_count' => 'integer', + 'interval_count' => 'integer', + 'billing_day' => 'integer', + 'tax_rate' => 'integer', + 'unit_subtotal' => 'integer', + 'unit_tax_amount' => 'integer', + 'unit_total_amount' => 'integer', + ]; + + public function settlementSchedules(): HasMany + { + return $this->hasMany(SettlementSchedule::class)->orderBy('sequence_no'); + } +} diff --git a/app/Models/SettlementSchedule.php b/app/Models/SettlementSchedule.php new file mode 100644 index 0000000..8e9b00d --- /dev/null +++ b/app/Models/SettlementSchedule.php @@ -0,0 +1,28 @@ + 'array', + 'sequence_no' => 'integer', + 'period_start' => 'date:Y-m-d', + 'period_end' => 'date:Y-m-d', + 'billing_date' => 'date:Y-m-d', + 'due_date' => 'date:Y-m-d', + 'subtotal' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + ]; + + public function settlement(): BelongsTo + { + return $this->belongsTo(Settlement::class); + } +} diff --git a/app/Services/BackupRestorer.php b/app/Services/BackupRestorer.php index 9032e41..12257d7 100644 --- a/app/Services/BackupRestorer.php +++ b/app/Services/BackupRestorer.php @@ -5,15 +5,19 @@ use Illuminate\Database\QueryException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class BackupRestorer { - /** エクスポート形式のバージョン。2 で customers から customer_signatures を分離した。 */ - public const VERSION = 2; + /** + * エクスポート形式のバージョン。 + * 2 で customers から customer_signatures を分離、3 で settlements / settlement_schedules を追加した。 + */ + public const VERSION = 3; /** 復元を受け付けるバージョン。1 は customers[].customer_signature を含む旧形式。 */ - public const SUPPORTED_VERSIONS = [1, 2]; + public const SUPPORTED_VERSIONS = [1, 2, 3]; /** * @return array{inserted: int, skipped: int, updated: int, errors: array} @@ -44,13 +48,30 @@ public function restore(array $data, string $mode): array return $row; }); - $this->restoreRows('quotes', $data['quotes'] ?? [], $mode, $summary, function (array $row): array { + $encodeItems = function (array $row): array { if (isset($row['items']) && is_array($row['items'])) { $row['items'] = json_encode($row['items'], JSON_UNESCAPED_UNICODE); } return $row; - }); + }; + + $this->restoreRows('quotes', $data['quotes'] ?? [], $mode, $summary, $encodeItems); + + $restoredSettlementIds = $this->restoreRows('settlements', $data['settlements'] ?? [], $mode, $summary, $encodeItems); + // JSON decode で int/string どちらにもなり得るため、文字列キーの set で照合する。 + $restoredSettlementIdSet = array_fill_keys(array_map('strval', $restoredSettlementIds), true); + + $scheduleRows = []; + foreach ($data['settlement_schedules'] ?? [] as $row) { + if (isset($restoredSettlementIdSet[(string) ($row['settlement_id'] ?? '')])) { + $scheduleRows[] = $row; + } else { + // skip された親や存在しない親の予定を、同じIDの別仕切書へ紐付けない。 + $summary['skipped']++; + } + } + $this->restoreRows('settlement_schedules', $scheduleRows, $mode, $summary, $encodeItems); }); return $summary; @@ -67,6 +88,15 @@ private function validateRows(array $data): void 'customers' => ['customer_name'], 'customer_signatures' => ['customer_id', 'signature'], 'quotes' => [], + 'settlements' => ['start_date', 'interval_unit'], + 'settlement_schedules' => ['settlement_id', 'sequence_no', 'period_start', 'period_end', 'billing_date'], + ]; + $nonNullableDefaultColumns = [ + 'settlements' => [ + 'status', 'interval_count', 'billing_timing', 'tax_rate', + 'unit_subtotal', 'unit_tax_amount', 'unit_total_amount', + ], + 'settlement_schedules' => ['subtotal', 'tax_amount', 'total_amount'], ]; foreach ($requiredColumns as $table => $columns) { @@ -101,6 +131,56 @@ private function validateRows(array $data): void ]); } } + + // default 付きカラムは省略を許すが、明示的な null / 非スカラ値は DB 例外になるため拒否する。 + foreach ($nonNullableDefaultColumns[$table] ?? [] as $column) { + if (array_key_exists($column, $row) + && (! is_scalar($row[$column]) || $row[$column] === '')) { + throw ValidationException::withMessages([ + 'file' => ["{$table}.{$index} の {$column} が不正です。"], + ]); + } + } + + if ($table === 'settlements') { + $validator = Validator::make($row, [ + 'status' => ['sometimes', 'in:draft,active,ended'], + 'start_date' => ['required', 'date'], + 'end_date' => ['nullable', 'required_without:occurrences_count', 'date', 'after_or_equal:start_date'], + 'occurrences_count' => ['nullable', 'required_without:end_date', 'integer', 'min:1'], + 'interval_unit' => ['required', 'in:month,year'], + 'interval_count' => ['sometimes', 'integer', 'min:1'], + 'billing_day' => ['nullable', 'integer', 'between:1,31'], + 'billing_timing' => ['sometimes', 'in:advance,arrears'], + 'tax_rate' => ['sometimes', 'integer', 'between:0,100'], + 'items' => ['nullable', 'array'], + ]); + if ($validator->fails()) { + throw ValidationException::withMessages([ + 'file' => ["{$table}.{$index} の値が不正です: {$validator->errors()->first()}"], + ]); + } + } + + if ($table === 'settlement_schedules') { + $validator = Validator::make($row, [ + 'settlement_id' => ['required', 'integer', 'min:1'], + 'sequence_no' => ['required', 'integer', 'min:1'], + 'period_start' => ['required', 'date'], + 'period_end' => ['required', 'date', 'after_or_equal:period_start'], + 'billing_date' => ['required', 'date'], + 'due_date' => ['nullable', 'date'], + 'subtotal' => ['sometimes', 'integer'], + 'tax_amount' => ['sometimes', 'integer'], + 'total_amount' => ['sometimes', 'integer'], + 'items' => ['nullable', 'array'], + ]); + if ($validator->fails()) { + throw ValidationException::withMessages([ + 'file' => ["{$table}.{$index} の値が不正です: {$validator->errors()->first()}"], + ]); + } + } } } } diff --git a/app/Support/SettlementPricing.php b/app/Support/SettlementPricing.php new file mode 100644 index 0000000..098147f --- /dev/null +++ b/app/Support/SettlementPricing.php @@ -0,0 +1,41 @@ +map(function ($item) { + $quantity = (int) ($item['quantity'] ?? 0); + $unitPrice = (int) ($item['unit_price'] ?? 0); + $item['total'] = $quantity * $unitPrice; + + return $item; + }); + + $subtotal = $items->sum('total'); + $taxRate = (int) ($data['tax_rate'] ?? 10); + $tax = (int) floor($subtotal * $taxRate / 100); + + $data['items'] = $items->values()->all(); + $data['unit_subtotal'] = $subtotal; + $data['unit_tax_amount'] = $tax; + $data['unit_total_amount'] = $subtotal + $tax; + + return $data; + } + + public static function subtotal(Collection|array $items): int + { + return collect($items)->sum(function ($item) { + return (int) ($item['quantity'] ?? 0) * (int) ($item['unit_price'] ?? 0); + }); + } +} diff --git a/app/Support/SettlementScheduleGenerator.php b/app/Support/SettlementScheduleGenerator.php new file mode 100644 index 0000000..9fe91e6 --- /dev/null +++ b/app/Support/SettlementScheduleGenerator.php @@ -0,0 +1,156 @@ +> + */ + public static function generate(Settlement $settlement): array + { + self::assertGeneratable($settlement); + + $startDate = CarbonImmutable::parse($settlement->start_date->toDateString()); + $endDate = $settlement->end_date + ? CarbonImmutable::parse($settlement->end_date->toDateString()) + : null; + $occurrencesCount = $settlement->occurrences_count; + $schedules = []; + + for ($index = 0; ; $index++) { + $sequenceNo = $index + 1; + if ($occurrencesCount !== null && $sequenceNo > $occurrencesCount) { + break; + } + + // 常に初回開始日から加算し、1月31日などの月末アンカーのドリフトを防ぐ。 + $periodStart = self::addInterval($startDate, $settlement, $index); + if ($endDate && $periodStart->greaterThan($endDate)) { + break; + } + + $periodEnd = self::addInterval($startDate, $settlement, $index + 1)->subDay(); + if ($endDate && $periodEnd->greaterThan($endDate)) { + $periodEnd = $endDate; + } + + $billingDate = self::billingDate($periodStart, $periodEnd, $settlement); + $schedules[] = [ + 'sequence_no' => $sequenceNo, + 'period_start' => $periodStart->toDateString(), + 'period_end' => $periodEnd->toDateString(), + 'billing_date' => $billingDate->toDateString(), + 'due_date' => null, + 'subtotal' => $settlement->unit_subtotal, + 'tax_amount' => $settlement->unit_tax_amount, + 'total_amount' => $settlement->unit_total_amount, + 'items' => $settlement->items ?? [], + ]; + } + + return $schedules; + } + + /** + * 基準日より前の回を保持し、当日以降の予定だけを現在条件で置き換える。 + */ + public static function sync(Settlement $settlement, ?CarbonInterface $asOf = null): Collection + { + $cutoff = CarbonImmutable::parse(($asOf ?? now())->toDateString()); + + DB::transaction(function () use ($settlement, $cutoff) { + $generated = self::generate($settlement); + $keptSequenceSet = $settlement->settlementSchedules() + ->whereDate('billing_date', '<', $cutoff->toDateString()) + ->pluck('sequence_no') + ->mapWithKeys(fn ($sequenceNo) => [(int) $sequenceNo => true]) + ->all(); + + $settlement->settlementSchedules() + ->whereDate('billing_date', '>=', $cutoff->toDateString()) + ->delete(); + + foreach ($generated as $schedule) { + if (isset($keptSequenceSet[(int) $schedule['sequence_no']])) { + continue; + } + + $settlement->settlementSchedules()->create($schedule); + } + }); + + return $settlement->settlementSchedules()->orderBy('sequence_no')->get(); + } + + private static function assertGeneratable(Settlement $settlement): void + { + if (! $settlement->start_date) { + throw new InvalidArgumentException('start_date is required.'); + } + + if (! $settlement->end_date && $settlement->occurrences_count === null) { + throw new InvalidArgumentException('end_date or occurrences_count is required.'); + } + + if ($settlement->end_date && $settlement->end_date->lt($settlement->start_date)) { + throw new InvalidArgumentException('end_date must be on or after start_date.'); + } + + if (! in_array($settlement->interval_unit, ['month', 'year'], true)) { + throw new InvalidArgumentException('interval_unit must be month or year.'); + } + + if ((int) $settlement->interval_count < 1) { + throw new InvalidArgumentException('interval_count must be at least 1.'); + } + + if ($settlement->occurrences_count !== null && $settlement->occurrences_count < 1) { + throw new InvalidArgumentException('occurrences_count must be at least 1.'); + } + + if (! in_array($settlement->billing_timing, ['advance', 'arrears'], true)) { + throw new InvalidArgumentException('billing_timing must be advance or arrears.'); + } + + if ($settlement->billing_day !== null && ($settlement->billing_day < 1 || $settlement->billing_day > 31)) { + throw new InvalidArgumentException('billing_day must be between 1 and 31.'); + } + } + + private static function addInterval( + CarbonImmutable $startDate, + Settlement $settlement, + int $multiplier, + ): CarbonImmutable { + $amount = $settlement->interval_count * $multiplier; + + return $settlement->interval_unit === 'month' + ? $startDate->addMonthsNoOverflow($amount) + : $startDate->addYearsNoOverflow($amount); + } + + private static function billingDate( + CarbonImmutable $periodStart, + CarbonImmutable $periodEnd, + Settlement $settlement, + ): CarbonImmutable { + $boundary = $settlement->billing_timing === 'arrears' ? $periodEnd : $periodStart; + if ($settlement->billing_day === null) { + return $boundary; + } + + $day = min($settlement->billing_day, $boundary->daysInMonth); + + return $boundary->startOfMonth()->day($day); + } +} diff --git a/database/migrations/2026_07_08_000001_create_settlements_table.php b/database/migrations/2026_07_08_000001_create_settlements_table.php new file mode 100644 index 0000000..3af208d --- /dev/null +++ b/database/migrations/2026_07_08_000001_create_settlements_table.php @@ -0,0 +1,65 @@ +id(); + $table->string('settlement_number')->nullable()->unique(); + $table->string('subject')->nullable(); + $table->string('status')->default('draft'); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->unsignedInteger('occurrences_count')->nullable(); + $table->string('interval_unit'); + $table->unsignedInteger('interval_count')->default(1); + $table->unsignedTinyInteger('billing_day')->nullable(); + $table->string('billing_timing')->default('advance'); + $table->string('payment_terms')->nullable(); + $table->unsignedInteger('tax_rate')->default(10); + $table->text('notes')->nullable(); + + // 発行者スナップショット + $table->foreignId('sender_profile_id')->nullable(); + $table->string('sender_company')->nullable(); + $table->string('sender_zip')->nullable(); + $table->string('sender_pref')->nullable(); + $table->string('sender_city')->nullable(); + $table->string('sender_address1')->nullable(); + $table->string('sender_address2')->nullable(); + $table->string('sender_person')->nullable(); + $table->string('sender_tel')->nullable(); + $table->string('sender_fax')->nullable(); + $table->longText('sender_logo_url')->nullable(); + + // 取引先スナップショット + $table->foreignId('customer_id')->nullable(); + $table->string('customer_name')->nullable(); + $table->string('customer_department')->nullable(); + $table->string('customer_person')->nullable(); + $table->string('customer_zip')->nullable(); + $table->string('customer_pref')->nullable(); + $table->string('customer_city')->nullable(); + $table->string('customer_address1')->nullable(); + $table->string('customer_address2')->nullable(); + $table->string('customer_tel')->nullable(); + + // 1回分の明細・金額 + $table->json('items')->nullable(); + $table->bigInteger('unit_subtotal')->default(0); + $table->bigInteger('unit_tax_amount')->default(0); + $table->bigInteger('unit_total_amount')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settlements'); + } +}; diff --git a/database/migrations/2026_07_08_000002_create_settlement_schedules_table.php b/database/migrations/2026_07_08_000002_create_settlement_schedules_table.php new file mode 100644 index 0000000..5a64ea5 --- /dev/null +++ b/database/migrations/2026_07_08_000002_create_settlement_schedules_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('settlement_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('sequence_no'); + $table->date('period_start'); + $table->date('period_end'); + $table->date('billing_date'); + $table->date('due_date')->nullable(); + $table->bigInteger('subtotal')->default(0); + $table->bigInteger('tax_amount')->default(0); + $table->bigInteger('total_amount')->default(0); + $table->json('items')->nullable(); + $table->timestamps(); + + $table->unique(['settlement_id', 'sequence_no']); + }); + } + + public function down(): void + { + Schema::dropIfExists('settlement_schedules'); + } +}; diff --git a/resources/js/App.tsx b/resources/js/App.tsx index b25e6e9..b0fa0e5 100644 --- a/resources/js/App.tsx +++ b/resources/js/App.tsx @@ -11,6 +11,9 @@ import QuoteList from './pages/QuoteList'; import QuoteForm from './pages/QuoteForm'; import Import from './pages/Import'; import QuotePreview from './pages/QuotePreview'; +import SettlementList from './pages/SettlementList'; +import SettlementForm from './pages/SettlementForm'; +import SettlementPreview from './pages/SettlementPreview'; import Summary from './pages/Summary'; import SenderProfiles from './pages/SenderProfiles'; import Customers from './pages/Customers'; @@ -74,6 +77,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/resources/js/components/Layout.tsx b/resources/js/components/Layout.tsx index 981f988..f82a23c 100644 --- a/resources/js/components/Layout.tsx +++ b/resources/js/components/Layout.tsx @@ -1,7 +1,7 @@ import { NavLink, useNavigate } from 'react-router-dom'; import clsx from 'clsx'; import { - FileText, FilePlus, Upload, BarChart3, Building2, Users, ShieldCheck, LogOut, Leaf, Database, type LucideIcon, + FileText, FilePlus, Upload, BarChart3, Building2, Users, ShieldCheck, LogOut, Leaf, Database, Repeat, type LucideIcon, } from 'lucide-react'; import type { ReactNode } from 'react'; import { useAuth } from '../context/AuthContext'; @@ -24,6 +24,7 @@ const NAV: NavGroup[] = [ { to: '/', label: '見積書一覧', icon: FileText, end: true }, { to: '/quotes/new', label: '見積書作成', icon: FilePlus }, { to: '/import', label: '見積書取込', icon: Upload }, + { to: '/settlements', label: '仕切書一覧', icon: Repeat }, { to: '/summary', label: '月末集計', icon: BarChart3 }, ], }, diff --git a/resources/js/components/ui.tsx b/resources/js/components/ui.tsx index f27b8af..eef68fd 100644 --- a/resources/js/components/ui.tsx +++ b/resources/js/components/ui.tsx @@ -87,6 +87,39 @@ export function Select({ className, children, ...props }: SelectHTMLAttributes void; + label?: ReactNode; + disabled?: boolean; +} +export function Switch({ checked, onChange, label, disabled }: SwitchProps) { + return ( + + ); +} + // ---------- Badge ---------- export function Badge({ className, children }: { className?: string; children: ReactNode }) { return ( diff --git a/resources/js/data/adapters/api.ts b/resources/js/data/adapters/api.ts index c5f8482..6004c51 100644 --- a/resources/js/data/adapters/api.ts +++ b/resources/js/data/adapters/api.ts @@ -5,6 +5,7 @@ import type { AuthUser, BackupPayload, Customer as TCustomer, EntityAdapter, ID, ImportResponse, ManagedUser, Quote as TQuote, RestoreCredentialsMode, RestoreResult, SenderProfile as TSenderProfile, + Settlement as TSettlement, SettlementSchedule as TSettlementSchedule, SettlementScheduleFilters, SetupStatus, StoredBackup, StoredRestoreResult, } from '../../types'; @@ -88,6 +89,17 @@ function makeEntity(path: string): EntityAdapter { export const Quote: EntityAdapter = makeEntity('quotes'); export const Customer: EntityAdapter = makeEntity('customers'); export const SenderProfile: EntityAdapter = makeEntity('sender-profiles'); +export const Settlement: EntityAdapter = makeEntity('settlements'); + +// 各回の請求予定データを取得する(フィルタ: settlement_id / from / to は billing_date に対する範囲)。 +export function listSettlementSchedules(filters: SettlementScheduleFilters = {}): Promise { + const params = new URLSearchParams(); + if (filters.settlement_id !== undefined && filters.settlement_id !== '') params.set('settlement_id', String(filters.settlement_id)); + if (filters.from) params.set('from', filters.from); + if (filters.to) params.set('to', filters.to); + const qs = params.toString(); + return request('GET', `/api/settlement-schedules${qs ? `?${qs}` : ''}`); +} // 御見積書ファイル(複数可)を multipart/form-data で取り込む。 // FormData は request() を通さない: JSON 化や手動 Content-Type 付与は diff --git a/resources/js/data/adapters/local.ts b/resources/js/data/adapters/local.ts index bee7b3e..23f5621 100644 --- a/resources/js/data/adapters/local.ts +++ b/resources/js/data/adapters/local.ts @@ -1,6 +1,10 @@ // localStorage アダプター(オフライン/デモ/テスト用)。 // API を使わずブラウザ内で完結する。e2e はこのアダプターで分離性を確保する。 -import type { Customer as TCustomer, EntityAdapter, ID, ImportResponse, ImportResult, LineItem, Quote as TQuote, SenderProfile as TSenderProfile } from '../../types'; +import type { + Customer as TCustomer, EntityAdapter, ID, ImportResponse, ImportResult, IntervalUnit, LineItem, + Quote as TQuote, SenderProfile as TSenderProfile, Settlement as TSettlement, + SettlementSchedule as TSettlementSchedule, SettlementScheduleFilters, +} from '../../types'; const NS = 'quotes'; @@ -89,6 +93,159 @@ SenderProfile.update = async (id, data) => { return r; }; +// --- 仕切書(settlements)と各回の請求予定(settlement_schedules) --- +// サーバ側 SettlementPricing / SettlementScheduleGenerator と同じ規則 +// (金額再計算、アンカー日からの no-overflow 加算、月末クランプ)を local で再現する。 +// デモ/e2e 用の簡易実装のため、更新時は過去回の保持を行わず全回を作り直す。 + +const SCHEDULE_KEY = 'settlement_schedules'; + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +// 常に初回開始日(アンカー)から加算し、月末周期のドリフトを防ぐ(1/31 + 1ヶ月 → 2/28|29)。 +function addInterval(iso: string, unit: IntervalUnit, amount: number): string { + const [y, m, d] = iso.split('-').map(Number); + const total = (m - 1) + (unit === 'year' ? amount * 12 : amount); + const ny = y + Math.floor(total / 12); + const nm = (total % 12) + 1; + const daysInMonth = new Date(ny, nm, 0).getDate(); + return `${ny}-${pad2(nm)}-${pad2(Math.min(d, daysInMonth))}`; +} + +function subDay(iso: string): string { + const [y, m, d] = iso.split('-').map(Number); + const dt = new Date(y, m - 1, d - 1); + return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`; +} + +// advance は期首、arrears は期末を基準に、billing_day 指定時は月内日数へクランプする。 +function billingDateOf(periodStart: string, periodEnd: string, s: TSettlement): string { + const boundary = s.billing_timing === 'arrears' ? periodEnd : periodStart; + if (s.billing_day == null) return boundary; + const [y, m] = boundary.split('-').map(Number); + const daysInMonth = new Date(y, m, 0).getDate(); + return `${y}-${pad2(m)}-${pad2(Math.min(s.billing_day, daysInMonth))}`; +} + +// SettlementPricing::apply 相当。items から 1回分の小計・税・税込額を再計算する。 +function applySettlementPricing(data: Partial): Partial { + const items = (data.items ?? []).map((it) => { + const quantity = Math.trunc(Number(it.quantity)) || 0; + const unitPrice = Math.trunc(Number(it.unit_price)) || 0; + return { ...it, total: quantity * unitPrice }; + }); + const subtotal = items.reduce((sum, it) => sum + it.total, 0); + const taxRate = Math.trunc(Number(data.tax_rate ?? 10)) || 0; + const tax = Math.floor((subtotal * taxRate) / 100); + return { ...data, items, unit_subtotal: subtotal, unit_tax_amount: tax, unit_total_amount: subtotal + tax }; +} + +// サーバの終了条件バリデーション(end_date/occurrences_count のいずれか必須)を local でも +// 再現する。ここで弾かないと generateSchedules() が黙って [] を返し、API では 422 になる +// 入力がローカルでは静かに通ってしまい、デモ/e2e での挙動差がトラブルシュートを難しくする。 +function assertEndCondition(s: Partial): void { + // generateSchedules() は end_date を `!s.end_date` で判定するため、空文字も未設定として扱う。 + if (s.end_date || s.occurrences_count != null) return; + const message = 'end_date か occurrences_count のいずれかが必要です。'; + const err = new Error(message) as Error & { status?: number; errors?: Record }; + err.status = 422; + err.errors = { end_date: [message], occurrences_count: [message] }; + throw err; +} + +// SettlementScheduleGenerator::generate 相当。終了条件が無い場合は生成しない。 +function generateSchedules(s: TSettlement): TSettlementSchedule[] { + if (!s.start_date || (!s.end_date && s.occurrences_count == null)) return []; + const intervalCount = Number(s.interval_count) || 1; + const rows: TSettlementSchedule[] = []; + + for (let index = 0; ; index++) { + const sequenceNo = index + 1; + if (s.occurrences_count != null && sequenceNo > s.occurrences_count) break; + + const periodStart = addInterval(s.start_date, s.interval_unit, intervalCount * index); + if (s.end_date && periodStart > s.end_date) break; + + let periodEnd = subDay(addInterval(s.start_date, s.interval_unit, intervalCount * (index + 1))); + if (s.end_date && periodEnd > s.end_date) periodEnd = s.end_date; + + rows.push({ + id: uid(), + settlement_id: s.id, + sequence_no: sequenceNo, + period_start: periodStart, + period_end: periodEnd, + billing_date: billingDateOf(periodStart, periodEnd, s), + due_date: null, + subtotal: s.unit_subtotal ?? 0, + tax_amount: s.unit_tax_amount ?? 0, + total_amount: s.unit_total_amount ?? 0, + items: s.items ?? [], + }); + } + + return rows; +} + +function replaceSchedules(s: TSettlement): void { + const others = read(SCHEDULE_KEY).filter((r) => String(r.settlement_id) !== String(s.id)); + write(SCHEDULE_KEY, [...others, ...generateSchedules(s)]); +} + +function schedulesOf(id: ID): TSettlementSchedule[] { + return read(SCHEDULE_KEY) + .filter((r) => String(r.settlement_id) === String(id)) + .sort((a, b) => a.sequence_no - b.sequence_no); +} + +const settlementBase = makeEntity>('settlements'); + +// API と同形状(settlement_schedules を含む)で返す。 +export const Settlement: EntityAdapter = { + async list(sort?: string) { + const rows = await settlementBase.list(sort); + return rows.map((r) => ({ ...r, settlement_schedules: schedulesOf(r.id) })); + }, + async get(id: ID) { + const row = await settlementBase.get(id); + return row ? { ...row, settlement_schedules: schedulesOf(row.id) } : null; + }, + async create(data: Partial) { + assertEndCondition(data); + const row = await settlementBase.create(applySettlementPricing(data)); + replaceSchedules(row); + return { ...row, settlement_schedules: schedulesOf(row.id) }; + }, + async update(id: ID, data: Partial) { + const existing = await settlementBase.get(id); + if (!existing) return null; + const merged = { ...existing, ...data }; + assertEndCondition(merged); + const row = await settlementBase.update(id, applySettlementPricing(merged)); + if (!row) return null; + replaceSchedules(row); + return { ...row, settlement_schedules: schedulesOf(row.id) }; + }, + async delete(id: ID) { + write(SCHEDULE_KEY, read(SCHEDULE_KEY).filter((r) => String(r.settlement_id) !== String(id))); + return settlementBase.delete(id); + }, +}; + +// GET /api/settlement-schedules と同じフィルタ・並び順(billing_date 昇順)。 +export async function listSettlementSchedules(filters: SettlementScheduleFilters = {}): Promise { + return read(SCHEDULE_KEY) + .filter((r) => { + if (filters.settlement_id !== undefined && filters.settlement_id !== '' && String(r.settlement_id) !== String(filters.settlement_id)) return false; + if (filters.from && r.billing_date < filters.from) return false; + if (filters.to && r.billing_date > filters.to) return false; + return true; + }) + .sort((a, b) => a.billing_date.localeCompare(b.billing_date) || a.sequence_no - b.sequence_no); +} + // 取込のモック。ファイル内容の解析はサーバー専用機能のため、local では実ファイルを // 解析せず、ファイル名から下書き Quote を 1 件生成して API と同形状の結果を返す。 // (API モードでの取込結果プレビューと同じ UI を e2e/デモで検証できるようにする) diff --git a/resources/js/data/store.ts b/resources/js/data/store.ts index 4fd0301..ddf60c4 100644 --- a/resources/js/data/store.ts +++ b/resources/js/data/store.ts @@ -4,12 +4,17 @@ // 切替: localStorage.setItem('zensales:adapter', 'local') import * as apiAdapter from './adapters/api'; import * as localAdapter from './adapters/local'; -import type { Customer as TCustomer, EntityAdapter, ImportResponse, Quote as TQuote, SenderProfile as TSenderProfile } from '../types'; +import type { + Customer as TCustomer, EntityAdapter, ImportResponse, Quote as TQuote, SenderProfile as TSenderProfile, + Settlement as TSettlement, SettlementSchedule as TSettlementSchedule, SettlementScheduleFilters, +} from '../types'; type Adapter = { Quote: EntityAdapter; Customer: EntityAdapter; SenderProfile: EntityAdapter; + Settlement: EntityAdapter; + listSettlementSchedules: (filters?: SettlementScheduleFilters) => Promise; importQuotes: (files: File[]) => Promise; seedIfEmpty: () => void; }; @@ -33,6 +38,8 @@ const adapter = pick(); export const Quote = adapter.Quote; export const Customer = adapter.Customer; export const SenderProfile = adapter.SenderProfile; +export const Settlement = adapter.Settlement; +export const listSettlementSchedules = adapter.listSettlementSchedules; export const importQuotes = adapter.importQuotes; export const seedIfEmpty = adapter.seedIfEmpty; diff --git a/resources/js/lib/calc.ts b/resources/js/lib/calc.ts index e11138b..4395bd5 100644 --- a/resources/js/lib/calc.ts +++ b/resources/js/lib/calc.ts @@ -2,7 +2,7 @@ // subtotal = Σ item.total // tax = floor(subtotal * tax_rate / 100) // total = subtotal + tax -import type { LineItem, Quote } from '../types'; +import type { LineItem } from '../types'; export function lineTotal(item: Pick): number { return Number(item.quantity || 0) * Number(item.unit_price || 0); @@ -15,7 +15,13 @@ export interface QuoteTotals { total: number; } -export function quoteTotals(quote: Pick | null | undefined): QuoteTotals { +// Quote・Settlement 共通(どちらも items + tax_rate から 1 回分の金額を出す) +interface Totalable { + items?: LineItem[] | null; + tax_rate?: number; +} + +export function quoteTotals(quote: Totalable | null | undefined): QuoteTotals { const items = quote?.items || []; const subtotal = items.reduce((sum, it) => sum + Number(it.total || 0), 0); const taxRate = Number(quote?.tax_rate ?? 10); diff --git a/resources/js/lib/format.ts b/resources/js/lib/format.ts index 5c6bdec..33149c9 100644 --- a/resources/js/lib/format.ts +++ b/resources/js/lib/format.ts @@ -1,5 +1,5 @@ // 日本語フォーマットユーティリティ -import type { QuoteStatus } from '../types'; +import type { BillingTiming, IntervalUnit, QuoteStatus, SettlementStatus } from '../types'; export const PREFECTURES: string[] = [ '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県', @@ -34,6 +34,38 @@ export const STATUS_STYLE: Record = { rejected: 'bg-danger/10 text-danger', }; +// 仕切書ステータス(issue #58) +export interface SettlementStatusOption { + value: SettlementStatus; + label: string; +} + +export const SETTLEMENT_STATUS_OPTIONS: SettlementStatusOption[] = [ + { value: 'draft', label: '下書き' }, + { value: 'active', label: '契約中' }, + { value: 'ended', label: '終了' }, +]; + +export const SETTLEMENT_STATUS_LABEL: Record = Object.fromEntries( + SETTLEMENT_STATUS_OPTIONS.map((s) => [s.value, s.label]) +) as Record; + +export const SETTLEMENT_STATUS_STYLE: Record = { + draft: 'bg-neutral-100 text-neutral-600', + active: 'bg-success/10 text-success', + ended: 'bg-neutral-200 text-neutral-500', +}; + +export const INTERVAL_UNIT_LABEL: Record = { + month: 'ヶ月', + year: '年', +}; + +export const BILLING_TIMING_OPTIONS: { value: BillingTiming; label: string }[] = [ + { value: 'advance', label: '期首(前払い)' }, + { value: 'arrears', label: '期末(後払い)' }, +]; + export function yen(n: number | string | null | undefined): string { const v = Number(n || 0); return '¥' + v.toLocaleString('ja-JP'); diff --git a/resources/js/pages/QuoteList.tsx b/resources/js/pages/QuoteList.tsx index a001b07..a69aedb 100644 --- a/resources/js/pages/QuoteList.tsx +++ b/resources/js/pages/QuoteList.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx'; import { Quote, Customer } from '../data/store'; import { quoteTotals } from '../lib/calc'; import { yen, jpDate, STATUS_OPTIONS, STATUS_LABEL, STATUS_STYLE } from '../lib/format'; -import { Button, Card, Input, Select, Badge, EmptyState } from '../components/ui'; +import { Button, Card, Input, Select, Badge, EmptyState, Switch } from '../components/ui'; import PageHeader from '../components/PageHeader'; import type { Customer as TCustomer, Quote as TQuote, QuoteStatus } from '../types'; @@ -26,6 +26,7 @@ export default function QuoteList() { const [query, setQuery] = useState(''); const [month, setMonth] = useState(''); const [customerId, setCustomerId] = useState(''); + const [createAsSettlement, setCreateAsSettlement] = useState(false); useEffect(() => { Quote.list('-created_date').then(setRows); @@ -55,7 +56,14 @@ export default function QuoteList() {
navigate('/quotes/new')}>新規作成} + actions={ + <> + + + + } /> {/* タブ */} diff --git a/resources/js/pages/SettlementForm.tsx b/resources/js/pages/SettlementForm.tsx new file mode 100644 index 0000000..6702f55 --- /dev/null +++ b/resources/js/pages/SettlementForm.tsx @@ -0,0 +1,323 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { Plus, Trash2, ArrowLeft } from 'lucide-react'; +import { Settlement, Customer, SenderProfile } from '../data/store'; +import { quoteTotals, EMPTY_LINE_ITEM } from '../lib/calc'; +import { yen, todayISO, SETTLEMENT_STATUS_OPTIONS, BILLING_TIMING_OPTIONS } from '../lib/format'; +import { Button, Card, Input, Select, Textarea, Field, useToast } from '../components/ui'; +import PageHeader from '../components/PageHeader'; +import type { + Customer as TCustomer, LineItem, Settlement as TSettlement, SenderProfile as TSenderProfile, +} from '../types'; + +const SENDER_KEYS = ['sender_company', 'sender_zip', 'sender_pref', 'sender_city', 'sender_address1', 'sender_address2', 'sender_person', 'sender_tel', 'sender_fax', 'sender_logo_url'] as const; +const CUSTOMER_KEYS = ['customer_name', 'customer_department', 'customer_person', 'customer_zip', 'customer_pref', 'customer_city', 'customer_address1', 'customer_address2', 'customer_tel'] as const; + +type SettlementForm = Omit & { id?: TSettlement['id'] }; +type EndMode = 'date' | 'count'; + +function blankSettlement(): SettlementForm { + return { + settlement_number: '', subject: '', status: 'draft', + start_date: todayISO(), end_date: '', occurrences_count: null, + interval_unit: 'month', interval_count: 1, + billing_day: null, billing_timing: 'advance', + payment_terms: '', tax_rate: 10, notes: '', + ...(Object.fromEntries(SENDER_KEYS.map((k) => [k, ''])) as Record<(typeof SENDER_KEYS)[number], string>), + ...(Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, ''])) as Record<(typeof CUSTOMER_KEYS)[number], string>), + sender_profile_id: '', customer_id: '', + items: [{ ...EMPTY_LINE_ITEM }], + }; +} + +export default function SettlementForm() { + const { id } = useParams<{ id: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const toast = useToast(); + + const [settlement, setSettlement] = useState(blankSettlement); + const [endMode, setEndMode] = useState('date'); + const [senders, setSenders] = useState([]); + const [customers, setCustomers] = useState([]); + const [loaded, setLoaded] = useState(!isEdit); + + useEffect(() => { + (async () => { + const [sp, cs] = await Promise.all([SenderProfile.list(), Customer.list('customer_name')]); + setSenders(sp); + setCustomers(cs); + + if (isEdit && id) { + const s = await Settlement.get(id); + if (s) { + setSettlement({ ...blankSettlement(), ...s, items: s.items?.length ? s.items : [{ ...EMPTY_LINE_ITEM }] }); + setEndMode(s.occurrences_count != null ? 'count' : 'date'); + } + setLoaded(true); + } else { + const def = sp.find((s) => s.is_default) || sp[0]; + setSettlement((prev) => ({ + ...prev, + ...(def ? applySender(def) : {}), + sender_profile_id: def?.id ?? '', + })); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const set = (k: K, v: SettlementForm[K]) => + setSettlement((s) => ({ ...s, [k]: v })); + + function applySender(sp: TSenderProfile): Partial { + return Object.fromEntries(SENDER_KEYS.map((k) => [k, sp[k] ?? ''])) as Partial; + } + function applyCustomer(c: TCustomer): Partial { + return Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, c[k] ?? ''])) as Partial; + } + + const onSelectSender = (sid: string) => { + const sp = senders.find((s) => String(s.id) === sid); + setSettlement((s) => ({ + ...s, + sender_profile_id: sid, + ...(sp ? applySender(sp) : Object.fromEntries(SENDER_KEYS.map((k) => [k, '']))), + })); + }; + const onSelectCustomer = (cid: string) => { + const c = customers.find((x) => String(x.id) === cid); + setSettlement((s) => ({ + ...s, + customer_id: cid, + ...(c ? applyCustomer(c) : Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, '']))), + })); + }; + + const onSelectEndMode = (mode: EndMode) => { + setEndMode(mode); + if (mode === 'date') set('occurrences_count', null); + else set('end_date', null); + }; + + // 品目 + const updateItem = (idx: number, key: keyof LineItem, value: string) => { + setSettlement((s) => { + const items = (s.items ?? []).map((it, i) => { + if (i !== idx) return it; + const next = { ...it, [key]: value } as LineItem; + if (key === 'quantity' || key === 'unit_price') { + next.total = Number(next.quantity || 0) * Number(next.unit_price || 0); + } + return next; + }); + return { ...s, items }; + }); + }; + const addItem = () => setSettlement((s) => ({ ...s, items: [...(s.items ?? []), { ...EMPTY_LINE_ITEM }] })); + const removeItem = (idx: number) => setSettlement((s) => ({ ...s, items: (s.items ?? []).filter((_, i) => i !== idx) })); + + const totals = useMemo(() => quoteTotals(settlement), [settlement]); + + const save = async () => { + if (!settlement.customer_name) { toast('取引先を選択してください', 'error'); return; } + if (!settlement.start_date) { toast('開始日を入力してください', 'error'); return; } + if (endMode === 'date' && !settlement.end_date) { toast('終了日を入力してください', 'error'); return; } + if (endMode === 'count' && !settlement.occurrences_count) { toast('回数を入力してください', 'error'); return; } + + // API は settlement_number を nullable+UNIQUE、sender_profile_id/customer_id を + // nullable+integer で検証するため、未選択/未入力の "" は null に正規化して送る + // ("" のままだと UNIQUE 制約や integer バリデーションに引っかかる)。 + const payload = { + ...settlement, + settlement_number: settlement.settlement_number || null, + sender_profile_id: settlement.sender_profile_id || null, + customer_id: settlement.customer_id || null, + }; + if (isEdit && id) { + await Settlement.update(id, payload); + toast('仕切書を更新しました'); + navigate(`/settlements/${id}`); + } else { + const created = await Settlement.create(payload); + toast('仕切書を作成しました'); + navigate(`/settlements/${created.id}`); + } + }; + + if (!loaded) return
読み込み中...
; + + const num = (v: number | string | null | undefined): number | string => (v === 0 || v ? v : ''); + + return ( +
+ + + + + } + /> + +
+
+ {/* 発行者 / 取引先 */} + +
+
+ + + + {senders.length === 0 && ( +

+ ※ 発行者が未登録です。基本情報マスタ から登録してください。 +

+ )} +
+
+ + + + {customers.length === 0 && ( +

+ ※ 取引先が未登録です。取引先マスタ から登録してください。 +

+ )} +
+
+
+ + {/* 仕切書情報 */} + +

仕切書情報

+
+ + set('settlement_number', e.target.value)} /> + + + + +
+ set('subject', e.target.value)} placeholder="件名を入力" /> +
+ + + set('start_date', e.target.value)} /> + + +
+ + {endMode === 'date' ? ( + set('end_date', e.target.value)} /> + ) : ( + set('occurrences_count', e.target.value ? Number(e.target.value) : null)} placeholder="回数" /> + )} +
+
+ + +
+ set('interval_count', Number(e.target.value) || 1)} /> + +
+
+ + + + + + set('billing_day', e.target.value ? Number(e.target.value) : null)} placeholder="例: 31" /> + + set('payment_terms', e.target.value)} placeholder="例: 月末締め翌月末払い" /> +
+
+ + {/* 品目(1回あたり) */} + +
+

品目(1回あたり)

+ +
+
+
+
+
品名
+
仕様
+
数量
+
単位
+
標準価格
+
納入単価
+
金額
+
+ {(settlement.items ?? []).map((it, idx) => ( +
+
updateItem(idx, 'name', e.target.value)} />
+
updateItem(idx, 'spec', e.target.value)} />
+
updateItem(idx, 'quantity', e.target.value)} />
+
updateItem(idx, 'unit', e.target.value)} placeholder="個" />
+
updateItem(idx, 'standard_price', e.target.value)} />
+
updateItem(idx, 'unit_price', e.target.value)} />
+
+ {yen(it.total)} + {(settlement.items ?? []).length > 1 && ( + + )} +
+
+ ))} +
+
+
+ + {/* 備考 */} + + +