财务台账:新增账户异常待办工作台与汇总卡片优化 #434
@ -9,6 +9,7 @@ use App\Utilities\FinanceLedgerAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
|
||||
@ -24,6 +25,252 @@ class FinanceLedger extends Controller
|
||||
return $template->download();
|
||||
}
|
||||
|
||||
public function unavailableBalanceTasks(Request $request)
|
||||
{
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$abnormalStatuses = array_values(array_diff(
|
||||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||||
[FinanceLedgerAccountStatus::NORMAL],
|
||||
));
|
||||
$handlingMethods = [
|
||||
'online' => '线上',
|
||||
'offline' => '线下',
|
||||
];
|
||||
$completionStatuses = [
|
||||
'completed' => '已完成',
|
||||
'in_progress' => '进行中',
|
||||
'not_started' => '待开始',
|
||||
'incomplete' => '未完成',
|
||||
];
|
||||
$filters = [
|
||||
'pending_short' => trim((string) $request->query('pending_short', '')),
|
||||
'pending_type' => trim((string) $request->query('pending_type', '')),
|
||||
'pending_status' => trim((string) $request->query('pending_status', '')),
|
||||
'pending_reason' => trim((string) $request->query('pending_reason', '')),
|
||||
'pending_method' => trim((string) $request->query('pending_method', '')),
|
||||
'pending_owner' => trim((string) $request->query('pending_owner', '')),
|
||||
];
|
||||
|
||||
$query = $connection->table('fin_accounts as accounts')
|
||||
->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
|
||||
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
|
||||
->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
|
||||
->whereIn('account_status.status', $abnormalStatuses);
|
||||
|
||||
if ($filters['pending_short'] !== '') {
|
||||
$query->where('accounts.account_short', 'like', '%' . $filters['pending_short'] . '%');
|
||||
}
|
||||
if ($filters['pending_type'] !== '') {
|
||||
$query->where(function ($effectiveType) use ($filters): void {
|
||||
$effectiveType->where(function ($baseType) use ($filters): void {
|
||||
$baseType->whereNull('type_override.id');
|
||||
if ($filters['pending_type'] === '__empty__') {
|
||||
$baseType->where(function ($blank): void {
|
||||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$baseType->where('accounts.account_type', $filters['pending_type']);
|
||||
}
|
||||
})->orWhere(function ($overrideType) use ($filters): void {
|
||||
$overrideType->whereNotNull('type_override.id');
|
||||
if ($filters['pending_type'] === '__empty__') {
|
||||
$overrideType->where(function ($blank): void {
|
||||
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$overrideType->where('type_override.account_type', $filters['pending_type']);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if ($filters['pending_status'] !== '' && array_key_exists($filters['pending_status'], FinanceLedgerAccountStatus::OPTIONS)) {
|
||||
$query->where('account_status.status', $filters['pending_status']);
|
||||
}
|
||||
if ($filters['pending_reason'] !== '') {
|
||||
$query->where('account_todo.abnormal_reason', 'like', '%' . $filters['pending_reason'] . '%');
|
||||
}
|
||||
if ($filters['pending_method'] !== '' && array_key_exists($filters['pending_method'], $handlingMethods)) {
|
||||
$query->where('account_todo.handling_method', $filters['pending_method']);
|
||||
}
|
||||
if ($filters['pending_owner'] !== '') {
|
||||
$query->where('account_todo.owner_name', 'like', '%' . $filters['pending_owner'] . '%');
|
||||
}
|
||||
|
||||
$summaryQuery = clone $query;
|
||||
$summary = [
|
||||
'count' => (clone $query)->count(),
|
||||
'balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
|
||||
'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2),
|
||||
'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
|
||||
];
|
||||
|
||||
$sortField = (string) $request->query('pending_sort', '');
|
||||
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
|
||||
$sortDirection = strtolower((string) $request->query('pending_direction', 'asc'));
|
||||
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$query->select([
|
||||
'accounts.*',
|
||||
'account_status.status as effective_account_status',
|
||||
'type_override.id as type_override_id',
|
||||
'type_override.account_type as effective_account_type',
|
||||
'account_todo.abnormal_reason as todo_abnormal_reason',
|
||||
'account_todo.handling_method as todo_handling_method',
|
||||
'account_todo.owner_name as todo_owner_name',
|
||||
'account_todo.started_at as todo_started_at',
|
||||
'account_todo.completed_at as todo_completed_at',
|
||||
'account_todo.current_progress as todo_current_progress',
|
||||
'account_todo.completion_status as todo_completion_status',
|
||||
]);
|
||||
|
||||
if ($sortField !== '') {
|
||||
$query->orderBy('account_todo.' . $sortField, $sortDirection);
|
||||
} else {
|
||||
$query->orderByDesc('accounts.blocked_amount');
|
||||
}
|
||||
$rows = $query
|
||||
->orderBy('accounts.company_name')
|
||||
->orderBy('accounts.account_short')
|
||||
->paginate(50)
|
||||
->appends($filters)
|
||||
->appends(array_filter([
|
||||
'pending_sort' => $sortField,
|
||||
'pending_direction' => $sortField !== '' ? $sortDirection : '',
|
||||
]))
|
||||
->withQueryString();
|
||||
|
||||
$rows->getCollection()->transform(static function ($row) {
|
||||
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
|
||||
if ($row->type_override_id !== null) {
|
||||
$row->account_type = (string) ($row->effective_account_type ?? '');
|
||||
}
|
||||
$row->todo_completion_status = $row->todo_completion_status ?: 'not_started';
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
$typeOptions = FinanceLedgerAccountType::options(
|
||||
$connection->table('fin_accounts')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||||
$connection->table('fin_account_type_overrides')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||||
);
|
||||
$reasonOptions = $connection->table('fin_account_todos')
|
||||
->whereNotNull('abnormal_reason')->where('abnormal_reason', '<>', '')
|
||||
->distinct()->orderBy('abnormal_reason')->pluck('abnormal_reason')->all();
|
||||
$ownerOptions = $connection->table('fin_account_todos')
|
||||
->whereNotNull('owner_name')->where('owner_name', '<>', '')
|
||||
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
|
||||
|
||||
return view('business.pages.financial-ledger.unavailable-balance-tasks', [
|
||||
'rows' => $rows,
|
||||
'summary' => $summary,
|
||||
'filters' => $filters,
|
||||
'sortField' => $sortField,
|
||||
'sortDirection' => $sortDirection,
|
||||
'backUrl' => route('business.pages.show', [
|
||||
'section' => 'financial-operations',
|
||||
'page' => 'ledger',
|
||||
]) . '?tab=accounts',
|
||||
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
|
||||
'handlingMethods' => $handlingMethods,
|
||||
'completionStatuses' => $completionStatuses,
|
||||
'accountStatusOptions' => array_intersect_key(
|
||||
FinanceLedgerAccountStatus::OPTIONS,
|
||||
array_flip($abnormalStatuses),
|
||||
),
|
||||
'accountTypeOptions' => $typeOptions,
|
||||
'reasonOptions' => $reasonOptions,
|
||||
'ownerOptions' => $ownerOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAccountTodo(Request $request)
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'abnormal_reason' => ['nullable', 'string', 'max:1000'],
|
||||
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
|
||||
'owner_name' => ['nullable', 'string', 'max:100'],
|
||||
'started_at' => ['nullable', 'date'],
|
||||
'completed_at' => ['nullable', 'date'],
|
||||
'current_progress' => ['nullable', 'string', 'max:100'],
|
||||
'completion_status' => ['nullable', 'string', Rule::in(['completed', 'in_progress', 'not_started', 'incomplete'])],
|
||||
]);
|
||||
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
abort_unless(
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||||
422,
|
||||
'账户简称不存在,请刷新待办清单后重试。',
|
||||
);
|
||||
|
||||
$abnormalStatuses = array_values(array_diff(
|
||||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||||
[FinanceLedgerAccountStatus::NORMAL],
|
||||
));
|
||||
$status = $connection->table('fin_account_statuses')
|
||||
->where('account_short', $accountShort)
|
||||
->value('status');
|
||||
abort_unless(
|
||||
$status !== null && in_array(FinanceLedgerAccountStatus::normalize((string) $status), $abnormalStatuses, true),
|
||||
422,
|
||||
'账户当前已不是异常状态,无需继续填写待办。',
|
||||
);
|
||||
|
||||
$now = now();
|
||||
$completionStatus = (string) ($data['completion_status'] ?? 'not_started');
|
||||
$values = [
|
||||
'abnormal_reason' => $data['abnormal_reason'] ?? null,
|
||||
'handling_method' => $data['handling_method'] ?? null,
|
||||
'owner_name' => $data['owner_name'] ?? null,
|
||||
'started_at' => filled($data['started_at'] ?? null)
|
||||
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
|
||||
: null,
|
||||
'completed_at' => filled($data['completed_at'] ?? null)
|
||||
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
|
||||
: null,
|
||||
'current_progress' => $data['current_progress'] ?? null,
|
||||
'completion_status' => $completionStatus,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus): void {
|
||||
$todos = $connection->table('fin_account_todos');
|
||||
$existing = $todos->where('account_short', $accountShort)->exists();
|
||||
if ($existing) {
|
||||
$todos->where('account_short', $accountShort)->update($values);
|
||||
} else {
|
||||
$todos->insert($values + [
|
||||
'account_short' => $accountShort,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($completionStatus === 'completed') {
|
||||
$connection->table('fin_account_statuses')
|
||||
->where('account_short', $accountShort)
|
||||
->update([
|
||||
'status' => FinanceLedgerAccountStatus::NORMAL,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with(
|
||||
'success',
|
||||
$completionStatus === 'completed'
|
||||
? '待办已完成,账户状态已自动设为正常'
|
||||
: '待办事项已保存',
|
||||
);
|
||||
}
|
||||
|
||||
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('fin_accounts') || $schema->hasTable('fin_account_todos')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->create('fin_account_todos', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('account_short', 100)->unique('fin_account_todos_account_unique');
|
||||
$table->text('abnormal_reason')->nullable();
|
||||
$table->string('handling_method', 20)->nullable();
|
||||
$table->string('owner_name', 100)->nullable();
|
||||
$table->dateTime('started_at')->nullable();
|
||||
$table->dateTime('completed_at')->nullable();
|
||||
$table->string('current_progress', 100)->nullable();
|
||||
$table->string('updated_by', 100)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())->dropIfExists('fin_account_todos');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('fin_account_todos') || $schema->hasColumn('fin_account_todos', 'completion_status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('fin_account_todos', function (Blueprint $table): void {
|
||||
$table->string('completion_status', 20)->default('not_started')->after('current_progress');
|
||||
$table->index(['completion_status'], 'fin_account_todos_completion_status_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('fin_account_todos') || ! $schema->hasColumn('fin_account_todos', 'completion_status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('fin_account_todos', function (Blueprint $table): void {
|
||||
$table->dropIndex('fin_account_todos_completion_status_index');
|
||||
$table->dropColumn('completion_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -84,10 +84,15 @@
|
||||
.ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; }
|
||||
.ledger-mapping-bulk .ledger-btn { height:1.95rem; }
|
||||
.ledger-download-status { flex-basis:100%; color:var(--vip-success, #53793E); font-size:.68rem; font-weight:600; line-height:1.4; text-align:right; }
|
||||
.ledger-summary { display:flex; flex-wrap:wrap; gap:.6rem 1.5rem; padding:.7rem 1rem; margin-bottom:.85rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.ledger-summary__item { display:flex; flex-direction:column; gap:.12rem; min-width:7rem; }
|
||||
.ledger-summary__item span { color:var(--vip-content-muted, #595959); font-size:.68rem; }
|
||||
.ledger-summary__item b { color:var(--vip-content-ink, #171717); font-size:.92rem; font-variant-numeric:tabular-nums; }
|
||||
.ledger-summary { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.65rem; margin-bottom:.85rem; }
|
||||
.ledger-summary__item { display:flex; flex-direction:column; justify-content:center; gap:.2rem; min-width:0; min-height:3.4rem; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
|
||||
.ledger-summary__item--available { background:#f3f7ee; }
|
||||
.ledger-summary__item--blocked { background:#fff5f5; }
|
||||
.ledger-summary__item--link { color:inherit; text-decoration:none; transition:border-color .15s ease, box-shadow .15s ease; }
|
||||
.ledger-summary__item--link:hover { border-color:rgba(184,0,0,.42); box-shadow:0 2px 6px rgba(184,0,0,.08); }
|
||||
.ledger-summary__item--link:focus-visible { outline:2px solid var(--vip-danger, #B80000); outline-offset:2px; }
|
||||
.ledger-summary__item span { overflow:hidden; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ledger-summary__item b { overflow:hidden; color:var(--vip-content-ink, #171717); font-size:.92rem; font-variant-numeric:tabular-nums; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.ledger-note { padding:.65rem .9rem; margin-bottom:.85rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:#fff; color:var(--vip-content-muted, #595959); font-size:.72rem; line-height:1.6; }
|
||||
.ledger-table-wrap { overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
|
||||
.ledger-table { width:100%; min-width:82rem; border-collapse:separate; border-spacing:0; font-size:.72rem; }
|
||||
@ -165,7 +170,8 @@
|
||||
.ledger-toast.is-error { border-color:var(--vip-danger, #B80000); color:var(--vip-danger, #B80000); }
|
||||
.ledger-source-tag { display:inline-flex; align-items:center; padding:.1rem .4rem; border-radius:.25rem; background:#f4f1eb; color:var(--vip-content-muted, #595959); font-size:.68rem; }
|
||||
.ledger-mono { font-variant-numeric:tabular-nums; }
|
||||
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } }
|
||||
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } .ledger-summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .ledger-summary { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
<div class="ledger-hero">
|
||||
@ -224,8 +230,8 @@
|
||||
</form>
|
||||
<div class="ledger-summary">
|
||||
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
|
||||
<div class="ledger-summary__item"><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
|
||||
<div class="ledger-summary__item"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></div>
|
||||
<div class="ledger-summary__item ledger-summary__item--available"><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
|
||||
<a class="ledger-summary__item ledger-summary__item--blocked ledger-summary__item--link" href="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="查看账户异常待办"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></a>
|
||||
<div class="ledger-summary__item"><span>每日可出金额口径</span><b>可用金额 − 不可用金额</b></div>
|
||||
</div>
|
||||
<div class="ledger-table-wrap ledger-accounts-table-wrap">
|
||||
|
||||
@ -0,0 +1,525 @@
|
||||
@php
|
||||
$money = static fn ($value): string => $value === null || $value === '' ? '' : number_format((float) $value, 2);
|
||||
@endphp
|
||||
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">账户异常待办</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<style>
|
||||
.pending-balance-page { margin:1.5rem 1.5rem 2rem; color:var(--vip-content-ink, #171717); font-size:.78rem; }
|
||||
.pending-balance-hero { display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:1rem; padding:1rem 1.15rem; margin-bottom:.9rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.75rem; background:#fff; }
|
||||
.pending-balance-hero h2 { margin:0 0 .3rem; font-size:.95rem; font-weight:700; }
|
||||
.pending-balance-hero p { margin:0; color:var(--vip-content-muted, #595959); line-height:1.55; }
|
||||
.pending-balance-back { display:inline-flex; align-items:center; gap:.35rem; color:var(--vip-content-accent-dark, #856632); font-weight:600; text-decoration:none; }
|
||||
.pending-balance-back:hover { color:#6A5129; }
|
||||
.pending-balance-summary { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.65rem; margin-bottom:.9rem; }
|
||||
.pending-balance-card { min-width:0; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
|
||||
.pending-balance-card.is-available { background:#f3f7ee; }
|
||||
.pending-balance-card.is-blocked { background:#fff5f5; }
|
||||
.pending-balance-card span { display:block; overflow:hidden; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pending-balance-card b { display:block; margin-top:.2rem; overflow:hidden; font-size:.92rem; font-variant-numeric:tabular-nums; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pending-balance-alert { display:flex; align-items:center; gap:.35rem; padding:.65rem .8rem; margin-bottom:.75rem; border:1px solid #bbf7d0; border-radius:.5rem; background:#f0fdf4; color:var(--vip-success, #53793E); font-size:.72rem; }
|
||||
.pending-balance-alert.is-error { border-color:rgba(184,0,0,.24); background:#fff5f5; color:var(--vip-danger, #B80000); }
|
||||
.pending-balance-toolbar { padding:.75rem 1rem; margin-bottom:.75rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
|
||||
.pending-balance-filter-grid { display:grid; grid-template-columns:repeat(6, minmax(0, 1fr)); gap:.55rem; }
|
||||
.pending-balance-field { display:flex; flex-direction:column; gap:.3rem; min-width:0; color:var(--vip-content-muted, #595959); font-size:.68rem; }
|
||||
.pending-balance-field input,
|
||||
.pending-balance-field select { width:100%; min-width:0; height:2.15rem; padding:0 .55rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
|
||||
.pending-balance-field input:focus,
|
||||
.pending-balance-field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); }
|
||||
.pending-balance-toolbar-actions { display:flex; justify-content:flex-end; gap:.55rem; margin-top:.65rem; }
|
||||
.pending-balance-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.15rem; padding:0 .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-accent-dark, #856632); font:inherit; font-weight:600; text-decoration:none; cursor:pointer; }
|
||||
.pending-balance-button.is-primary { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; }
|
||||
.pending-balance-table-hint { margin:0 0 .35rem; color:var(--vip-content-muted, #595959); font-size:.66rem; text-align:right; }
|
||||
.pending-balance-table-wrap { max-height:calc(100vh - 360px); min-height:20rem; overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
|
||||
.pending-balance-table { width:100%; min-width:72rem; border-collapse:separate; border-spacing:0; font-size:.72rem; }
|
||||
.pending-balance-table th,
|
||||
.pending-balance-table td { padding:.32rem .52rem; border-right:1px solid #e8e3da; border-bottom:1px solid #e8e3da; line-height:1.4; text-align:left; vertical-align:middle; white-space:nowrap; }
|
||||
.pending-balance-table tbody td { overflow:hidden; text-overflow:ellipsis; }
|
||||
.pending-balance-table th { position:sticky; top:0; z-index:4; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 0 var(--vip-border, #D8D2C7); font-weight:700; text-align:center; }
|
||||
.pending-balance-table th:last-child,
|
||||
.pending-balance-table td:last-child { border-right:0; }
|
||||
.pending-balance-table tbody tr:last-child td { border-bottom:0; }
|
||||
.pending-balance-table tbody tr:hover td { background:#fcfaf4; }
|
||||
.pending-balance-table .num { font-variant-numeric:tabular-nums; text-align:right; }
|
||||
.pending-balance-sort { display:inline-flex; align-items:center; justify-content:center; gap:.2rem; color:inherit; text-decoration:none; }
|
||||
.pending-balance-sort:hover { color:var(--vip-content-accent-dark, #856632); }
|
||||
.pending-balance-sort .material-icons-outlined { font-size:.9rem; }
|
||||
.pending-balance-task-input { width:100%; min-width:0; height:1.75rem; padding:0 .42rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
|
||||
.pending-balance-task-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.14); }
|
||||
.pending-balance-task-input:disabled { background:#f6f4ef; color:var(--vip-content-muted, #595959); cursor:not-allowed; }
|
||||
.pending-balance-progress { display:flex; align-items:center; gap:.3rem; min-width:0; }
|
||||
.pending-balance-save { display:inline-grid; flex:0 0 auto; place-items:center; width:1.75rem; height:1.75rem; padding:0; border:1px solid var(--vip-content-accent-dark, #856632); border-radius:.35rem; background:var(--vip-content-accent-dark, #856632); color:#fff; cursor:pointer; }
|
||||
.pending-balance-save:hover { border-color:#6A5129; background:#6A5129; }
|
||||
.pending-balance-status { display:inline-flex; align-items:center; justify-content:center; min-height:1.45rem; padding:.08rem .35rem; border:1px solid transparent; border-radius:999px; font-size:.68rem; font-weight:700; }
|
||||
.pending-column-resizer { position:absolute; top:0; right:0; z-index:6; width:.45rem; height:100%; cursor:col-resize; touch-action:none; }
|
||||
.pending-column-resizer::after { content:""; position:absolute; top:20%; bottom:20%; left:50%; width:1px; background:transparent; transition:background .15s ease; }
|
||||
.pending-column-resizer:hover::after,
|
||||
.pending-column-resizer.is-active::after { background:var(--vip-content-accent-dark, #856632); }
|
||||
.pending-column-resizer:focus { outline:none; }
|
||||
.pending-column-resizer:focus-visible::after { width:2px; background:var(--vip-content-accent-dark, #856632); }
|
||||
body.is-resizing-pending-column { cursor:col-resize; user-select:none; }
|
||||
.pending-balance-status.is-normal { border-color:rgba(83,121,62,.3); background:#f3f7ee; color:var(--vip-success, #53793E); }
|
||||
.pending-balance-status.is-online_banking_abnormal { border-color:rgba(184,0,0,.3); background:#fff5f5; color:var(--vip-danger, #B80000); }
|
||||
.pending-balance-status.is-limited { border-color:rgba(147,95,7,.32); background:#fff8eb; color:var(--vip-warning, #935F07); }
|
||||
.pending-balance-status.is-business_deregistered { border-color:rgba(89,89,89,.28); background:#f3f1ec; color:var(--vip-content-muted, #595959); }
|
||||
.pending-balance-empty { padding:3rem 1rem; color:var(--vip-content-muted, #595959); text-align:center; }
|
||||
.pending-balance-page.is-paginating .pending-balance-table-wrap { opacity:.58; pointer-events:none; }
|
||||
.ledger-pagination { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.65rem .9rem; color:var(--vip-content-muted, #595959); font-size:.72rem; }
|
||||
.ledger-pagination a { margin-left:.75rem; color:var(--vip-content-accent-dark, #856632); font-weight:600; text-decoration:none; }
|
||||
.ledger-pagination__controls { display:flex; align-items:center; justify-content:flex-end; gap:.45rem; margin-left:auto; }
|
||||
.ledger-page-box { display:inline-flex; align-items:center; gap:.1rem; height:1.7rem; }
|
||||
.ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.2rem; height:1.5rem; padding:0; border:0; background:transparent; color:var(--vip-content-accent-dark, #856632); font:inherit; text-decoration:none; }
|
||||
.ledger-page-box__arrow:hover { color:#6A5129; }
|
||||
.ledger-page-box__arrow:focus:not(:focus-visible) { outline:none; }
|
||||
.ledger-page-box__arrow:focus-visible { outline:1px solid var(--vip-content-accent-dark, #856632); outline-offset:0; border-radius:2px; }
|
||||
.ledger-page-box__triangle { width:0; height:0; border-top:5px solid transparent; border-bottom:5px solid transparent; }
|
||||
.ledger-page-box__triangle.is-previous { border-right:8px solid currentColor; }
|
||||
.ledger-page-box__triangle.is-next { border-left:8px solid currentColor; }
|
||||
.ledger-page-box__arrow.is-disabled { color:#b9b3a9; cursor:not-allowed; }
|
||||
.ledger-page-box__current { min-width:2.2rem; padding:0 .15rem; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; }
|
||||
.pending-balance-page__status { color:var(--vip-content-muted, #595959); font-size:.68rem; }
|
||||
@media (max-width:1100px) { .pending-balance-filter-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width:900px) { .pending-balance-summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width:700px) { .pending-balance-filter-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width:560px) { .pending-balance-page { margin:1rem .75rem 1.5rem; } .pending-balance-summary, .pending-balance-filter-grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
<section class="pending-balance-page" aria-labelledby="pending-balance-title">
|
||||
<header class="pending-balance-hero">
|
||||
<div>
|
||||
<h2 id="pending-balance-title">账户异常待办</h2>
|
||||
<p>账户状态为“网银异常”“限额”或“工商注销”时自动进入本清单;不与其他页面的待办事项合并。</p>
|
||||
</div>
|
||||
<a class="pending-balance-back" href="{{ $backUrl }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">arrow_back</span>
|
||||
返回账户总表
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="pending-balance-summary">
|
||||
<div class="pending-balance-card"><span>待处理账户</span><b>{{ number_format((int) ($summary['count'] ?? 0)) }}</b></div>
|
||||
<div class="pending-balance-card is-blocked"><span>不可用金额合计</span><b>{{ $money($summary['blocked'] ?? 0) }}</b></div>
|
||||
<div class="pending-balance-card is-available"><span>可用金额合计</span><b>{{ $money($summary['balance'] ?? 0) }}</b></div>
|
||||
<div class="pending-balance-card"><span>每日可出金额合计</span><b>{{ $money($summary['daily_available'] ?? 0) }}</b></div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="pending-balance-alert" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span>{{ session('success') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="pending-balance-alert is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error</span>{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form class="pending-balance-toolbar" method="GET" action="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="筛选账户异常待办">
|
||||
@if ($sortField !== '')
|
||||
<input type="hidden" name="pending_sort" value="{{ $sortField }}">
|
||||
<input type="hidden" name="pending_direction" value="{{ $sortDirection }}">
|
||||
@endif
|
||||
<div class="pending-balance-filter-grid">
|
||||
<label class="pending-balance-field">
|
||||
<span>账户简称</span>
|
||||
<input name="pending_short" value="{{ $filters['pending_short'] }}" placeholder="输入账户简称">
|
||||
</label>
|
||||
<label class="pending-balance-field">
|
||||
<span>账户类型</span>
|
||||
<select name="pending_type">
|
||||
<option value="">全部类型</option>
|
||||
@foreach ($accountTypeOptions as $accountType)
|
||||
<option value="{{ $accountType }}" @selected($filters['pending_type'] === $accountType)>{{ $accountType }}</option>
|
||||
@endforeach
|
||||
<option value="__empty__" @selected($filters['pending_type'] === '__empty__')>未填写</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pending-balance-field">
|
||||
<span>账户状态</span>
|
||||
<select name="pending_status">
|
||||
<option value="">全部状态</option>
|
||||
@foreach ($accountStatusOptions as $statusCode => $statusLabel)
|
||||
<option value="{{ $statusCode }}" @selected($filters['pending_status'] === $statusCode)>{{ $statusLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="pending-balance-field">
|
||||
<span>异常原因</span>
|
||||
<select name="pending_reason">
|
||||
<option value="">全部原因</option>
|
||||
@foreach ($reasonOptions as $reason)
|
||||
<option value="{{ $reason }}" @selected($filters['pending_reason'] === $reason)>{{ $reason }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="pending-balance-field">
|
||||
<span>办理方式</span>
|
||||
<select name="pending_method">
|
||||
<option value="">全部方式</option>
|
||||
@foreach ($handlingMethods as $methodValue => $methodLabel)
|
||||
<option value="{{ $methodValue }}" @selected($filters['pending_method'] === $methodValue)>{{ $methodLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="pending-balance-field">
|
||||
<span>责任人员</span>
|
||||
<select name="pending_owner">
|
||||
<option value="">全部人员</option>
|
||||
@foreach ($ownerOptions as $owner)
|
||||
<option value="{{ $owner }}" @selected($filters['pending_owner'] === $owner)>{{ $owner }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pending-balance-toolbar-actions">
|
||||
<button class="pending-balance-button is-primary" type="submit">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
查询
|
||||
</button>
|
||||
<a class="pending-balance-button" href="{{ route('business.financial-ledger.unavailable-balances') }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
重置
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@php
|
||||
$sortQuery = request()->query();
|
||||
unset($sortQuery['pending_page']);
|
||||
$sortUrl = static fn (string $field): string => route(
|
||||
'business.financial-ledger.unavailable-balances',
|
||||
array_merge($sortQuery, [
|
||||
'pending_sort' => $field,
|
||||
'pending_direction' => $sortField === $field && $sortDirection === 'asc' ? 'desc' : 'asc',
|
||||
]),
|
||||
);
|
||||
@endphp
|
||||
|
||||
<p class="pending-balance-table-hint">拖动表头右侧边界可调整列宽,双击边界恢复默认</p>
|
||||
<div class="pending-balance-table-wrap">
|
||||
<table class="pending-balance-table" data-pending-table data-column-storage-key="caiwu.financial-ledger.pending-columns.v1.{{ auth()->id() }}.{{ company_id() }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-pending-column-key="company_name">公司名称</th>
|
||||
<th data-pending-column-key="account_short">账户简称</th>
|
||||
<th data-pending-column-key="account_type">账户类型</th>
|
||||
<th data-pending-column-key="account_status">账户状态</th>
|
||||
<th data-pending-column-key="account_no">账号</th>
|
||||
<th data-pending-column-key="bank_name">开户行</th>
|
||||
<th data-pending-column-key="bank_city_branch">开户城市/网点</th>
|
||||
<th data-pending-column-key="abnormal_reason">异常原因</th>
|
||||
<th data-pending-column-key="handling_method">办理方式</th>
|
||||
<th data-pending-column-key="owner_name">责任人员</th>
|
||||
<th data-pending-column-key="started_at" @if ($sortField === 'started_at') aria-sort="{{ $sortDirection === 'asc' ? 'ascending' : 'descending' }}" @endif>
|
||||
<a class="pending-balance-sort" href="{{ $sortUrl('started_at') }}" title="按开始时间排序">
|
||||
开始时间
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $sortField === 'started_at' ? ($sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}</span>
|
||||
</a>
|
||||
</th>
|
||||
<th data-pending-column-key="completed_at" @if ($sortField === 'completed_at') aria-sort="{{ $sortDirection === 'asc' ? 'ascending' : 'descending' }}" @endif>
|
||||
<a class="pending-balance-sort" href="{{ $sortUrl('completed_at') }}" title="按完成时间排序">
|
||||
完成时间
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $sortField === 'completed_at' ? ($sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}</span>
|
||||
</a>
|
||||
</th>
|
||||
<th data-pending-column-key="current_progress">当前进度</th>
|
||||
<th data-pending-column-key="completion_status">是否完成</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-pending-tbody>
|
||||
@forelse ($rows as $row)
|
||||
<tr data-pending-account="{{ $row->account_short }}">
|
||||
<td>
|
||||
<form id="pending-task-form-{{ $loop->index }}" method="POST" action="{{ route('business.financial-ledger.accounts.todo') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="account_short" value="{{ $row->account_short }}">
|
||||
</form>
|
||||
{{ $row->company_name ?: '未填写' }}
|
||||
</td>
|
||||
<td>{{ $row->account_short ?: '未填写' }}</td>
|
||||
<td>{{ $row->account_type ?: '未填写' }}</td>
|
||||
<td><span class="pending-balance-status is-{{ $row->account_status }}">{{ \App\Utilities\FinanceLedgerAccountStatus::label($row->account_status) }}</span></td>
|
||||
<td>{{ $row->account_no ?: '未填写' }}</td>
|
||||
<td>{{ $row->bank_name ?: '未填写' }}</td>
|
||||
<td>{{ $row->bank_city_branch ?: '未填写' }}</td>
|
||||
<td><input class="pending-balance-task-input" form="pending-task-form-{{ $loop->index }}" name="abnormal_reason" value="{{ $row->todo_abnormal_reason }}" placeholder="填写异常原因" @disabled(! $canEdit)></td>
|
||||
<td>
|
||||
<select class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="handling_method" aria-label="办理方式" @disabled(! $canEdit)>
|
||||
<option value="">未选择</option>
|
||||
@foreach ($handlingMethods as $methodValue => $methodLabel)
|
||||
<option value="{{ $methodValue }}" @selected(($row->todo_handling_method ?? '') === $methodValue)>{{ $methodLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</td>
|
||||
<td><input class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="owner_name" value="{{ $row->todo_owner_name }}" placeholder="填写责任人员" @disabled(! $canEdit)></td>
|
||||
<td><input class="pending-balance-task-input is-compact" type="datetime-local" form="pending-task-form-{{ $loop->index }}" name="started_at" value="{{ $row->todo_started_at ? \Illuminate\Support\Carbon::parse($row->todo_started_at)->format('Y-m-d\TH:i') : '' }}" @disabled(! $canEdit)></td>
|
||||
<td><input class="pending-balance-task-input is-compact" type="datetime-local" form="pending-task-form-{{ $loop->index }}" name="completed_at" value="{{ $row->todo_completed_at ? \Illuminate\Support\Carbon::parse($row->todo_completed_at)->format('Y-m-d\TH:i') : '' }}" @disabled(! $canEdit)></td>
|
||||
<td>
|
||||
<div class="pending-balance-progress">
|
||||
<input class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="current_progress" value="{{ $row->todo_current_progress }}" placeholder="填写当前进度" @disabled(! $canEdit)>
|
||||
@if ($canEdit)
|
||||
<button class="pending-balance-save" form="pending-task-form-{{ $loop->index }}" type="submit" title="保存待办" aria-label="保存 {{ $row->account_short }} 的待办">
|
||||
<span class="material-icons-outlined" aria-hidden="true">save</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="completion_status" aria-label="是否完成" @disabled(! $canEdit)>
|
||||
@foreach ($completionStatuses as $completionValue => $completionLabel)
|
||||
<option value="{{ $completionValue }}" @selected(($row->todo_completion_status ?? 'not_started') === $completionValue)>{{ $completionLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="14" class="pending-balance-empty">当前没有账户异常待办</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($rows->total() > 0)
|
||||
<div class="ledger-pagination" data-pending-pagination>
|
||||
<span>共 {{ number_format($rows->total()) }} 条 · 每页 {{ number_format($rows->perPage()) }} 条</span>
|
||||
<div class="ledger-pagination__controls">
|
||||
<span class="pending-balance-page__status" data-pending-page-status role="status" aria-live="polite" hidden></span>
|
||||
<nav class="ledger-page-box" data-pending-page-box aria-label="账户异常待办分页">
|
||||
@if ($rows->onFirstPage())
|
||||
<span class="ledger-page-box__arrow is-disabled" aria-disabled="true" title="已经是第一页"><span class="ledger-page-box__triangle is-previous" aria-hidden="true"></span></span>
|
||||
@else
|
||||
<a class="ledger-page-box__arrow" href="{{ $rows->previousPageUrl() }}" aria-label="上一页" title="上一页"><span class="ledger-page-box__triangle is-previous" aria-hidden="true"></span></a>
|
||||
@endif
|
||||
<span class="ledger-page-box__current">{{ $rows->currentPage() }} / {{ $rows->lastPage() }}</span>
|
||||
@if ($rows->hasMorePages())
|
||||
<a class="ledger-page-box__arrow" href="{{ $rows->nextPageUrl() }}" aria-label="下一页" title="下一页"><span class="ledger-page-box__triangle is-next" aria-hidden="true"></span></a>
|
||||
@else
|
||||
<span class="ledger-page-box__arrow is-disabled" aria-disabled="true" title="已经是最后一页"><span class="ledger-page-box__triangle is-next" aria-hidden="true"></span></span>
|
||||
@endif
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const table = document.querySelector('[data-pending-table]');
|
||||
if (!table) return;
|
||||
const headers = [...table.querySelectorAll('thead th[data-pending-column-key]')];
|
||||
if (headers.length === 0) return;
|
||||
|
||||
const storageKey = table.dataset.columnStorageKey || 'caiwu.financial-ledger.pending-columns';
|
||||
const minimumWidth = 56;
|
||||
const maximumWidth = 720;
|
||||
const clampWidth = (value) => Math.min(maximumWidth, Math.max(minimumWidth, Math.round(Number(value) || 0)));
|
||||
const defaults = new Map(headers.map((header) => [
|
||||
header.dataset.pendingColumnKey,
|
||||
Math.max(minimumWidth, Math.round(header.getBoundingClientRect().width * 10) / 10),
|
||||
]));
|
||||
const widths = new Map(defaults);
|
||||
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
|
||||
if (saved?.widths && typeof saved.widths === 'object') {
|
||||
Object.entries(saved.widths).forEach(([key, value]) => {
|
||||
if (widths.has(key)) widths.set(key, clampWidth(value));
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const group = document.createElement('colgroup');
|
||||
group.dataset.pendingColumnGroup = 'true';
|
||||
headers.forEach((header) => {
|
||||
const column = document.createElement('col');
|
||||
column.dataset.pendingColumnKey = header.dataset.pendingColumnKey;
|
||||
group.appendChild(column);
|
||||
});
|
||||
table.insertBefore(group, table.firstChild);
|
||||
|
||||
const applyWidths = () => {
|
||||
let totalWidth = 0;
|
||||
headers.forEach((header) => {
|
||||
const key = header.dataset.pendingColumnKey;
|
||||
const width = clampWidth(widths.get(key) ?? defaults.get(key));
|
||||
const column = group.querySelector('col[data-pending-column-key="' + key + '"]');
|
||||
if (column) column.style.width = width + 'px';
|
||||
const resizer = header.querySelector('[data-pending-column-resizer]');
|
||||
resizer?.setAttribute('aria-valuenow', String(width));
|
||||
totalWidth += width;
|
||||
});
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.width = totalWidth + 'px';
|
||||
table.style.minWidth = totalWidth + 'px';
|
||||
};
|
||||
|
||||
const persist = () => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
widths: Object.fromEntries(widths),
|
||||
}));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
headers.forEach((header) => {
|
||||
const key = header.dataset.pendingColumnKey;
|
||||
const link = header.querySelector('.pending-balance-sort');
|
||||
const label = (link?.firstChild?.textContent || header.textContent).trim() || '未命名字段';
|
||||
const resizer = document.createElement('span');
|
||||
resizer.className = 'pending-column-resizer';
|
||||
resizer.dataset.pendingColumnResizer = 'true';
|
||||
resizer.tabIndex = 0;
|
||||
resizer.setAttribute('role', 'separator');
|
||||
resizer.setAttribute('aria-orientation', 'vertical');
|
||||
resizer.setAttribute('aria-label', '调整“' + label + '”列宽');
|
||||
resizer.setAttribute('aria-valuemin', String(minimumWidth));
|
||||
resizer.setAttribute('aria-valuemax', String(maximumWidth));
|
||||
resizer.title = '拖动调整列宽,双击恢复默认';
|
||||
|
||||
const setWidth = (value, save = false) => {
|
||||
widths.set(key, clampWidth(value));
|
||||
applyWidths();
|
||||
if (save) persist();
|
||||
};
|
||||
|
||||
resizer.addEventListener('pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startWidth = header.getBoundingClientRect().width;
|
||||
resizer.classList.add('is-active');
|
||||
document.body.classList.add('is-resizing-pending-column');
|
||||
|
||||
const move = (moveEvent) => {
|
||||
setWidth(startWidth + moveEvent.clientX - startX);
|
||||
};
|
||||
const finish = () => {
|
||||
document.removeEventListener('pointermove', move);
|
||||
document.removeEventListener('pointerup', finish);
|
||||
document.removeEventListener('pointercancel', finish);
|
||||
resizer.classList.remove('is-active');
|
||||
document.body.classList.remove('is-resizing-pending-column');
|
||||
persist();
|
||||
};
|
||||
document.addEventListener('pointermove', move);
|
||||
document.addEventListener('pointerup', finish);
|
||||
document.addEventListener('pointercancel', finish);
|
||||
});
|
||||
resizer.addEventListener('keydown', (event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const width = Number(widths.get(key)) || header.getBoundingClientRect().width;
|
||||
setWidth(width + (event.key === 'ArrowLeft' ? -1 : 1) * (event.shiftKey ? 24 : 8), true);
|
||||
});
|
||||
resizer.addEventListener('dblclick', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setWidth(defaults.get(key), true);
|
||||
});
|
||||
resizer.addEventListener('click', (event) => event.stopPropagation());
|
||||
header.appendChild(resizer);
|
||||
});
|
||||
|
||||
applyWidths();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const page = document.querySelector('.pending-balance-page');
|
||||
if (!page) return;
|
||||
|
||||
let pendingRequest = null;
|
||||
const tableWrap = () => page.querySelector('.pending-balance-table-wrap');
|
||||
|
||||
const setPageStatus = (message, isError = false, hideAfter = 0) => {
|
||||
const status = page.querySelector('[data-pending-page-status]');
|
||||
if (!status) return;
|
||||
status.hidden = message === '';
|
||||
status.textContent = message;
|
||||
status.style.color = isError ? 'var(--vip-danger, #B80000)' : '';
|
||||
if (hideAfter > 0) {
|
||||
window.setTimeout(() => {
|
||||
status.hidden = true;
|
||||
status.textContent = '';
|
||||
}, hideAfter);
|
||||
}
|
||||
};
|
||||
|
||||
const bindPageLinks = () => {
|
||||
page.querySelectorAll('.ledger-page-box__arrow[href]').forEach((link) => {
|
||||
if (link.dataset.ajaxBound === '1') return;
|
||||
link.dataset.ajaxBound = '1';
|
||||
link.addEventListener('click', async (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
|
||||
const currentBody = page.querySelector('[data-pending-tbody]');
|
||||
const currentPagination = page.querySelector('[data-pending-pagination]');
|
||||
const currentWrap = tableWrap();
|
||||
if (!currentBody || !currentPagination || !currentWrap) {
|
||||
window.location.assign(link.href);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = currentWrap.scrollTop;
|
||||
const scrollLeft = currentWrap.scrollLeft;
|
||||
const targetPage = new URL(link.href, window.location.href).searchParams.get('page');
|
||||
|
||||
pendingRequest?.abort();
|
||||
const request = new AbortController();
|
||||
pendingRequest = request;
|
||||
page.classList.add('is-paginating');
|
||||
currentWrap.setAttribute('aria-busy', 'true');
|
||||
setPageStatus(targetPage ? '正在加载第 ' + targetPage + ' 页…' : '正在加载…');
|
||||
|
||||
try {
|
||||
const response = await fetch(link.href, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Accept': 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
signal: request.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('分页加载失败(HTTP ' + response.status + ')');
|
||||
|
||||
const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
const nextBody = nextDocument.querySelector('[data-pending-tbody]');
|
||||
const nextPagination = nextDocument.querySelector('[data-pending-pagination]');
|
||||
if (!nextBody || !nextPagination) throw new Error('分页内容解析失败');
|
||||
|
||||
currentBody.replaceWith(document.importNode(nextBody, true));
|
||||
currentPagination.replaceWith(document.importNode(nextPagination, true));
|
||||
if (window.history?.replaceState) {
|
||||
window.history.replaceState({ ...window.history.state, pendingBalanceAjax: true }, '', link.href);
|
||||
}
|
||||
|
||||
const nextWrap = tableWrap();
|
||||
if (nextWrap) {
|
||||
nextWrap.scrollTop = scrollTop;
|
||||
nextWrap.scrollLeft = scrollLeft;
|
||||
nextWrap.removeAttribute('aria-busy');
|
||||
}
|
||||
page.classList.remove('is-paginating');
|
||||
bindPageLinks();
|
||||
setPageStatus(targetPage ? '已加载第 ' + targetPage + ' 页' : '下一页已加载', false, 1600);
|
||||
} catch (error) {
|
||||
if (pendingRequest === request) {
|
||||
page.classList.remove('is-paginating');
|
||||
currentWrap.removeAttribute('aria-busy');
|
||||
if (error.name !== 'AbortError') {
|
||||
setPageStatus(error.message || '分页加载失败', true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pendingRequest === request) pendingRequest = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
bindPageLinks();
|
||||
})();
|
||||
</script>
|
||||
</x-slot>
|
||||
</x-layouts.admin>
|
||||
@ -697,6 +697,14 @@ Route::post('workspace/financial-ledger/mapping/payable/apply', [\App\Http\Contr
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.mapping.payable.apply');
|
||||
|
||||
Route::get('workspace/financial-operations/ledger/unavailable-balances', [\App\Http\Controllers\Business\FinanceLedger::class, 'unavailableBalanceTasks'])
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.unavailable-balances');
|
||||
|
||||
Route::post('workspace/financial-ledger/accounts/todo', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountTodo'])
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.accounts.todo');
|
||||
|
||||
Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) {
|
||||
abort_unless($page === null || $page === 'ledger', 404);
|
||||
|
||||
|
||||
@ -35,6 +35,20 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function unavailableBalancesUrl(): string
|
||||
{
|
||||
return route('business.financial-ledger.unavailable-balances', [
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function accountTodoUrl(): string
|
||||
{
|
||||
return route('business.financial-ledger.accounts.todo', [
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function statusUrl(): string
|
||||
{
|
||||
return route('business.financial-ledger.accounts.status', [
|
||||
@ -107,12 +121,202 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
|
||||
->assertSee('恢复默认')
|
||||
->assertSee('可用金额合计')
|
||||
->assertSee('不可用金额合计')
|
||||
->assertSee('href="' . $this->unavailableBalancesUrl() . '"', false)
|
||||
->assertSee('查看账户异常待办')
|
||||
->assertSee('可用金额 − 不可用金额')
|
||||
->assertSee('未填写')
|
||||
->assertDontSee('data-account-filter-state', false)
|
||||
->assertSee('value="normal" selected', false);
|
||||
}
|
||||
|
||||
public function test_unavailable_balance_tasks_page_is_separate_and_only_lists_blocked_accounts(): void
|
||||
{
|
||||
$connection = DB::connection('business_mirror');
|
||||
$accounts = [
|
||||
[
|
||||
'company_name' => '待办测试公司',
|
||||
'account_short' => '待办测试账户',
|
||||
'account_no' => '6222000000000099',
|
||||
'account_type' => '公户',
|
||||
'bank_name' => '待办测试银行',
|
||||
'bank_city_branch' => '上海 / 待办测试支行',
|
||||
'current_balance' => 100,
|
||||
'blocked_amount' => 25.5,
|
||||
'daily_available' => 74.5,
|
||||
'owner' => '待办负责人',
|
||||
'next_action' => '核实冻结原因',
|
||||
'remark' => '独立待办备注',
|
||||
],
|
||||
[
|
||||
'company_name' => '零金额测试公司',
|
||||
'account_short' => '零金额测试账户',
|
||||
'current_balance' => 100,
|
||||
'blocked_amount' => 0,
|
||||
'daily_available' => 100,
|
||||
],
|
||||
[
|
||||
'company_name' => '正常状态测试公司',
|
||||
'account_short' => '正常状态测试账户',
|
||||
'current_balance' => 100,
|
||||
'blocked_amount' => 50,
|
||||
'daily_available' => 50,
|
||||
],
|
||||
[
|
||||
'company_name' => '待办测试公司二',
|
||||
'account_short' => '待办测试账户二',
|
||||
'account_no' => '6222000000000100',
|
||||
'account_type' => '个人卡',
|
||||
'bank_name' => '待办测试银行二',
|
||||
'bank_city_branch' => '杭州 / 待办测试支行',
|
||||
'current_balance' => 100,
|
||||
'blocked_amount' => 10,
|
||||
'daily_available' => 90,
|
||||
],
|
||||
];
|
||||
foreach ($accounts as $account) {
|
||||
$connection->table('fin_accounts')->insert($account);
|
||||
}
|
||||
$connection->table('fin_account_statuses')->insert([
|
||||
[
|
||||
'account_short' => '待办测试账户',
|
||||
'status' => 'limited',
|
||||
'updated_by' => 'Test User',
|
||||
],
|
||||
[
|
||||
'account_short' => '正常状态测试账户',
|
||||
'status' => 'normal',
|
||||
'updated_by' => 'Test User',
|
||||
],
|
||||
[
|
||||
'account_short' => '待办测试账户二',
|
||||
'status' => 'online_banking_abnormal',
|
||||
'updated_by' => 'Test User',
|
||||
],
|
||||
]);
|
||||
$connection->table('fin_account_type_overrides')->insert([
|
||||
'account_short' => '待办测试账户',
|
||||
'account_type' => '网商银行',
|
||||
'updated_by' => 'Test User',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($this->unavailableBalancesUrl())
|
||||
->assertOk()
|
||||
->assertSee('账户异常待办')
|
||||
->assertSee('不与其他页面的待办事项合并')
|
||||
->assertSee('待办测试公司')
|
||||
->assertSee('data-pending-account="待办测试账户"', false)
|
||||
->assertSee('不可用金额合计')
|
||||
->assertSee('35.50')
|
||||
->assertSee('限额')
|
||||
->assertSee('网商银行')
|
||||
->assertSee('6222000000000099')
|
||||
->assertSee('待办测试银行')
|
||||
->assertSee('上海 / 待办测试支行')
|
||||
->assertSee('异常原因')
|
||||
->assertSee('办理方式')
|
||||
->assertSee('当前进度')
|
||||
->assertSee('是否完成')
|
||||
->assertSee('name="pending_short"', false)
|
||||
->assertSee('name="pending_type"', false)
|
||||
->assertSee('name="pending_status"', false)
|
||||
->assertSee('name="pending_reason"', false)
|
||||
->assertSee('name="pending_method"', false)
|
||||
->assertSee('name="pending_owner"', false)
|
||||
->assertSee('按开始时间排序')
|
||||
->assertSee('按完成时间排序')
|
||||
->assertDontSee('name="pending_q"', false)
|
||||
->assertSee('data-pending-tbody', false)
|
||||
->assertSee('data-pending-pagination', false)
|
||||
->assertSee('data-pending-table', false)
|
||||
->assertSee('拖动表头右侧边界可调整列宽')
|
||||
->assertSee('ledger-page-box', false)
|
||||
->assertSee('1 / 1', false)
|
||||
->assertSee('data-pending-account="待办测试账户二"', false)
|
||||
->assertDontSee('data-pending-account="正常状态测试账户"', false)
|
||||
->assertDontSee('data-pending-account="零金额测试账户"', false);
|
||||
|
||||
$this->loginAs()->post($this->accountTodoUrl(), [
|
||||
'account_short' => '待办测试账户',
|
||||
'abnormal_reason' => '账户冻结',
|
||||
'handling_method' => 'online',
|
||||
'owner_name' => '待办负责人',
|
||||
'started_at' => '2026-10-21T09:00',
|
||||
'completed_at' => '2026-10-21T11:30',
|
||||
'current_progress' => '处理中',
|
||||
'completion_status' => 'in_progress',
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('fin_account_todos', [
|
||||
'account_short' => '待办测试账户',
|
||||
'abnormal_reason' => '账户冻结',
|
||||
'handling_method' => 'online',
|
||||
'owner_name' => '待办负责人',
|
||||
'current_progress' => '处理中',
|
||||
'completion_status' => 'in_progress',
|
||||
'updated_by' => 'Test User',
|
||||
], 'business_mirror');
|
||||
|
||||
$connection->table('fin_account_todos')->insert([
|
||||
'account_short' => '待办测试账户二',
|
||||
'abnormal_reason' => '网银证书过期',
|
||||
'handling_method' => 'offline',
|
||||
'owner_name' => '其他负责人',
|
||||
'started_at' => '2026-10-21 10:00:00',
|
||||
'current_progress' => '待联系银行',
|
||||
'completion_status' => 'not_started',
|
||||
'updated_by' => 'Test User',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($this->unavailableBalancesUrl())
|
||||
->assertOk()
|
||||
->assertSee('账户冻结')
|
||||
->assertSee('待办负责人')
|
||||
->assertSee('处理中')
|
||||
->assertSee('进行中');
|
||||
|
||||
$filteredUrl = $this->unavailableBalancesUrl() . '?' . http_build_query([
|
||||
'pending_short' => '待办测试账户',
|
||||
'pending_type' => '网商银行',
|
||||
'pending_status' => 'limited',
|
||||
'pending_reason' => '账户冻结',
|
||||
'pending_method' => 'online',
|
||||
'pending_owner' => '待办负责人',
|
||||
]);
|
||||
$this->loginAs()->get($filteredUrl)
|
||||
->assertOk()
|
||||
->assertSee('data-pending-account="待办测试账户"', false)
|
||||
->assertDontSee('data-pending-account="待办测试账户二"', false);
|
||||
|
||||
$sortResponse = $this->loginAs()->get(
|
||||
$this->unavailableBalancesUrl() . '?pending_sort=started_at&pending_direction=desc'
|
||||
)->assertOk()->assertSee('aria-sort="descending"', false);
|
||||
preg_match_all('/data-pending-account="([^"]+)"/u', $sortResponse->getContent(), $sortedAccounts);
|
||||
$this->assertSame(['待办测试账户二', '待办测试账户'], array_values(array_intersect(
|
||||
$sortedAccounts[1],
|
||||
['待办测试账户', '待办测试账户二'],
|
||||
)));
|
||||
|
||||
$this->loginAs()->post($this->accountTodoUrl(), [
|
||||
'account_short' => '待办测试账户',
|
||||
'abnormal_reason' => '账户冻结',
|
||||
'handling_method' => 'online',
|
||||
'owner_name' => '待办负责人',
|
||||
'started_at' => '2026-10-21T09:00',
|
||||
'completed_at' => '2026-10-21T12:00',
|
||||
'current_progress' => '已处理',
|
||||
'completion_status' => 'completed',
|
||||
])->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('fin_account_statuses', [
|
||||
'account_short' => '待办测试账户',
|
||||
'status' => 'normal',
|
||||
], 'business_mirror');
|
||||
|
||||
$this->loginAs()->get($this->unavailableBalancesUrl())
|
||||
->assertOk()
|
||||
->assertDontSee('data-pending-account="待办测试账户"', false);
|
||||
}
|
||||
|
||||
public function test_financial_ledger_tab_forms_submit_to_query_tab_routes(): void
|
||||
{
|
||||
$pageUrl = $this->pageUrl();
|
||||
@ -406,7 +610,12 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
|
||||
'account_short' => '测试账户',
|
||||
'account_should_keep' => '否',
|
||||
])->assertForbidden();
|
||||
$this->withExceptionHandling()->loginAs($viewer)->post($this->accountTodoUrl(), [
|
||||
'account_short' => '测试账户',
|
||||
'current_progress' => '处理中',
|
||||
])->assertForbidden();
|
||||
|
||||
$this->assertDatabaseCount('fin_account_statuses', 0, 'business_mirror');
|
||||
$this->assertDatabaseCount('fin_account_todos', 0, 'business_mirror');
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user