财务台账:新增不保留账户处理待办 #444
@ -188,6 +188,229 @@ class FinanceLedger extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function unretainedAccountTasks(Request $request)
|
||||
{
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$handlingMethods = [
|
||||
'online' => '线上',
|
||||
'offline' => '线下',
|
||||
];
|
||||
$completionStatuses = [
|
||||
'completed' => '已完成',
|
||||
'in_progress' => '进行中',
|
||||
'not_started' => '待开始',
|
||||
];
|
||||
$filters = [
|
||||
'keep_short' => trim((string) $request->query('keep_short', '')),
|
||||
'keep_type' => trim((string) $request->query('keep_type', '')),
|
||||
'keep_status' => trim((string) $request->query('keep_status', '')),
|
||||
'keep_method' => trim((string) $request->query('keep_method', '')),
|
||||
'keep_owner' => trim((string) $request->query('keep_owner', '')),
|
||||
'keep_progress' => trim((string) $request->query('keep_progress', '')),
|
||||
];
|
||||
|
||||
$query = $connection->table('fin_accounts as accounts')
|
||||
->leftJoin('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_keep_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
|
||||
->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.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',
|
||||
]);
|
||||
FinanceLedgerAccountShouldKeep::applyUnretainedCondition(
|
||||
$query,
|
||||
'accounts.account_should_keep',
|
||||
'accounts.company_plan',
|
||||
);
|
||||
|
||||
if ($filters['keep_short'] !== '') {
|
||||
$query->where('accounts.account_short', 'like', '%' . $filters['keep_short'] . '%');
|
||||
}
|
||||
if ($filters['keep_type'] !== '') {
|
||||
$query->where(function ($effectiveType) use ($filters): void {
|
||||
$effectiveType->where(function ($baseType) use ($filters): void {
|
||||
$baseType->whereNull('type_override.id');
|
||||
if ($filters['keep_type'] === '__empty__') {
|
||||
$baseType->where(function ($blank): void {
|
||||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$baseType->where('accounts.account_type', $filters['keep_type']);
|
||||
}
|
||||
})->orWhere(function ($overrideType) use ($filters): void {
|
||||
$overrideType->whereNotNull('type_override.id');
|
||||
if ($filters['keep_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['keep_type']);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if ($filters['keep_status'] !== '' && array_key_exists($filters['keep_status'], FinanceLedgerAccountStatus::OPTIONS)) {
|
||||
if ($filters['keep_status'] === FinanceLedgerAccountStatus::NORMAL) {
|
||||
$query->where(function ($normalStatus): void {
|
||||
$normalStatus->whereNull('account_status.status')
|
||||
->orWhere('account_status.status', FinanceLedgerAccountStatus::NORMAL);
|
||||
});
|
||||
} else {
|
||||
$query->where('account_status.status', $filters['keep_status']);
|
||||
}
|
||||
}
|
||||
if ($filters['keep_method'] !== '' && array_key_exists($filters['keep_method'], $handlingMethods)) {
|
||||
$query->where('account_todo.handling_method', $filters['keep_method']);
|
||||
}
|
||||
if ($filters['keep_owner'] !== '') {
|
||||
$query->where('account_todo.owner_name', 'like', '%' . $filters['keep_owner'] . '%');
|
||||
}
|
||||
if ($filters['keep_progress'] !== '' && array_key_exists($filters['keep_progress'], $completionStatuses)) {
|
||||
if ($filters['keep_progress'] === 'not_started') {
|
||||
$query->where(function ($notStarted): void {
|
||||
$notStarted->whereNull('account_todo.current_progress')
|
||||
->orWhere('account_todo.current_progress', 'not_started');
|
||||
});
|
||||
} else {
|
||||
$query->where('account_todo.current_progress', $filters['keep_progress']);
|
||||
}
|
||||
}
|
||||
|
||||
$summaryQuery = clone $query;
|
||||
$summary = [
|
||||
'count' => (clone $query)->count(),
|
||||
'in_progress' => (clone $summaryQuery)->where('account_todo.current_progress', 'in_progress')->count(),
|
||||
'not_started' => (clone $summaryQuery)->where(function ($notStarted): void {
|
||||
$notStarted->whereNull('account_todo.current_progress')
|
||||
->orWhere('account_todo.current_progress', 'not_started');
|
||||
})->count(),
|
||||
'completed' => (clone $summaryQuery)->where('account_todo.current_progress', 'completed')->count(),
|
||||
];
|
||||
|
||||
$sortField = (string) $request->query('keep_sort', '');
|
||||
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
|
||||
$sortDirection = strtolower((string) $request->query('keep_direction', 'asc'));
|
||||
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
|
||||
if ($sortField !== '') {
|
||||
$query->orderBy('account_todo.' . $sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->orderBy('accounts.company_name')
|
||||
->orderBy('accounts.account_short')
|
||||
->paginate(50)
|
||||
->appends($filters)
|
||||
->appends(array_filter([
|
||||
'keep_sort' => $sortField,
|
||||
'keep_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_current_progress = $row->todo_current_progress ?: '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(),
|
||||
);
|
||||
$ownerOptions = $connection->table('fin_account_keep_todos')
|
||||
->whereNotNull('owner_name')->where('owner_name', '<>', '')
|
||||
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
|
||||
|
||||
return view('business.pages.financial-ledger.unretained-account-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' => FinanceLedgerAccountStatus::OPTIONS,
|
||||
'accountTypeOptions' => $typeOptions,
|
||||
'ownerOptions' => $ownerOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAccountKeepTodo(Request $request)
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改不保留账户待办。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
|
||||
'owner_name' => ['nullable', 'string', 'max:100'],
|
||||
'started_at' => ['nullable', 'date'],
|
||||
'completed_at' => ['nullable', 'date'],
|
||||
'current_progress' => ['required', 'string', Rule::in(['completed', 'in_progress', 'not_started'])],
|
||||
]);
|
||||
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$account = $connection->table('fin_accounts')
|
||||
->where('account_short', $accountShort)
|
||||
->first(['account_should_keep', 'company_plan']);
|
||||
abort_if($account === null, 422, '账户简称不存在,请刷新待办清单后重试。');
|
||||
abort_unless(
|
||||
FinanceLedgerAccountShouldKeep::display(
|
||||
$account->account_should_keep ?? null,
|
||||
$account->company_plan ?? null,
|
||||
) === FinanceLedgerAccountShouldKeep::NO,
|
||||
422,
|
||||
'账户当前已设置为保留,无需继续填写不保留账户待办。',
|
||||
);
|
||||
|
||||
$now = now();
|
||||
$values = [
|
||||
'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' => (string) $data['current_progress'],
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$todos = $connection->table('fin_account_keep_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,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '不保留账户待办已保存');
|
||||
}
|
||||
|
||||
public function updateAccountTodo(Request $request, FinanceLedgerCalculator $calculator)
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class FinanceLedgerAccountShouldKeep
|
||||
{
|
||||
public const YES = '是';
|
||||
@ -42,4 +44,22 @@ class FinanceLedgerAccountShouldKeep
|
||||
|
||||
return str_contains($plan, '保留') ? self::YES : self::NO;
|
||||
}
|
||||
|
||||
public static function applyUnretainedCondition(
|
||||
Builder $query,
|
||||
string $explicitColumn = 'account_should_keep',
|
||||
string $companyPlanColumn = 'company_plan',
|
||||
): Builder {
|
||||
return $query->where(function (Builder $keep) use ($explicitColumn, $companyPlanColumn): void {
|
||||
$keep->where($explicitColumn, self::NO)
|
||||
->orWhere(function (Builder $inferred) use ($explicitColumn, $companyPlanColumn): void {
|
||||
$inferred->where(function (Builder $blank) use ($explicitColumn): void {
|
||||
$blank->whereNull($explicitColumn)->orWhere($explicitColumn, '');
|
||||
})
|
||||
->whereNotNull($companyPlanColumn)
|
||||
->where($companyPlanColumn, '<>', '')
|
||||
->where($companyPlanColumn, 'not like', '%保留%');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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_keep_todos')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->create('fin_account_keep_todos', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('account_short', 100)->unique('fin_account_keep_todos_account_unique');
|
||||
$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', 20)->default('not_started');
|
||||
$table->string('updated_by', 100)->nullable();
|
||||
$table->timestamps();
|
||||
$table->index(['current_progress'], 'fin_account_keep_todos_progress_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())->dropIfExists('fin_account_keep_todos');
|
||||
}
|
||||
};
|
||||
@ -159,6 +159,7 @@ class FinanceLedgerPageData
|
||||
$summaryQuery = clone $query;
|
||||
$summary = [
|
||||
'count' => (clone $query)->count(),
|
||||
'unretained' => FinanceLedgerAccountShouldKeep::applyUnretainedCondition(clone $query)->count(),
|
||||
'current_balance' => round((float) (clone $summaryQuery)->sum('current_balance'), 2),
|
||||
'balance' => round((float) (clone $summaryQuery)->sum('daily_available'), 2),
|
||||
'blocked' => round((float) (clone $summaryQuery)->sum('blocked_amount'), 2),
|
||||
|
||||
@ -263,6 +263,7 @@
|
||||
</form>
|
||||
<div class="ledger-summary">
|
||||
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
|
||||
<a class="ledger-summary__item ledger-summary__item--link" href="{{ route('business.financial-ledger.unretained-accounts') }}" aria-label="查看不保留账户处理待办"><span>不保留账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['unretained'] ?? 0)) }}</b></a>
|
||||
<div class="ledger-summary__item ledger-summary__item--available" data-ledger-summary-available><span>可用金额合计(万元)</span><b title="{{ $moneyYuanTitle($ledgerAccountSummary['balance'] ?? 0) }}">{{ $moneyWan($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
|
||||
<a class="ledger-summary__item ledger-summary__item--blocked ledger-summary__item--link" data-ledger-summary-blocked href="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="查看账户异常待办"><span>不可用金额合计(万元)</span><b title="{{ $moneyYuanTitle($ledgerAccountSummary['blocked'] ?? 0) }}">{{ $moneyWan($ledgerAccountSummary['blocked'] ?? 0) }}</b></a>
|
||||
<div class="ledger-summary__item"><span>金额口径</span><b>正常计可用,异常计不可用</b></div>
|
||||
|
||||
@ -0,0 +1,521 @@
|
||||
@php
|
||||
$dateTimeValue = static fn ($value): string => $value
|
||||
? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d\TH:i')
|
||||
: '';
|
||||
@endphp
|
||||
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">不保留账户处理待办</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<style>
|
||||
.keep-task-page { margin:1.5rem 1.5rem 2rem; color:var(--vip-content-ink, #171717); font-size:.78rem; }
|
||||
.keep-task-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; }
|
||||
.keep-task-hero h2 { margin:0 0 .3rem; font-size:.95rem; font-weight:700; }
|
||||
.keep-task-hero p { margin:0; color:var(--vip-content-muted, #595959); line-height:1.55; }
|
||||
.keep-task-back { display:inline-flex; align-items:center; gap:.35rem; color:var(--vip-content-accent-dark, #856632); font-weight:600; text-decoration:none; }
|
||||
.keep-task-back:hover { color:#6A5129; }
|
||||
.keep-task-summary { display:flex; flex-wrap:wrap; gap:.65rem; margin-bottom:.9rem; }
|
||||
.keep-task-card { display:flex; flex:0 0 14rem; flex-direction:column; justify-content:center; width:14rem; min-width:14rem; 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); }
|
||||
.keep-task-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; }
|
||||
.keep-task-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; }
|
||||
.keep-task-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; }
|
||||
.keep-task-alert.is-error { border-color:rgba(184,0,0,.24); background:#fff5f5; color:var(--vip-danger, #B80000); }
|
||||
.keep-task-toolbar { padding:.75rem 1rem; margin-bottom:.75rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
|
||||
.keep-task-filter-grid { display:grid; grid-template-columns:repeat(6, minmax(0, 1fr)); gap:.55rem; }
|
||||
.keep-task-field { display:flex; flex-direction:column; gap:.3rem; min-width:0; color:var(--vip-content-muted, #595959); font-size:.68rem; }
|
||||
.keep-task-field input,
|
||||
.keep-task-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; }
|
||||
.keep-task-field input:focus,
|
||||
.keep-task-field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); }
|
||||
.keep-task-toolbar-actions { display:flex; justify-content:flex-end; gap:.55rem; margin-top:.65rem; }
|
||||
.keep-task-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; }
|
||||
.keep-task-button.is-primary { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; }
|
||||
.keep-task-table-hint { margin:0 0 .35rem; color:var(--vip-content-muted, #595959); font-size:.66rem; text-align:right; }
|
||||
.keep-task-table-wrap { max-height:calc(100vh - 360px); min-height:20rem; overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
|
||||
.keep-task-table { width:100%; min-width:72rem; border-collapse:separate; border-spacing:0; font-size:.72rem; }
|
||||
.keep-task-table th,
|
||||
.keep-task-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; }
|
||||
.keep-task-table tbody td { overflow:hidden; text-overflow:ellipsis; }
|
||||
.keep-task-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; }
|
||||
.keep-task-table th:last-child,
|
||||
.keep-task-table td:last-child { border-right:0; }
|
||||
.keep-task-table tbody tr:last-child td { border-bottom:0; }
|
||||
.keep-task-table tbody tr:hover td { background:#fcfaf4; }
|
||||
.keep-task-sort { display:inline-flex; align-items:center; justify-content:center; gap:.2rem; color:inherit; text-decoration:none; }
|
||||
.keep-task-sort:hover { color:var(--vip-content-accent-dark, #856632); }
|
||||
.keep-task-sort .material-icons-outlined { font-size:.9rem; }
|
||||
.keep-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; }
|
||||
.keep-task-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.14); }
|
||||
.keep-task-input:disabled { background:#f6f4ef; color:var(--vip-content-muted, #595959); cursor:not-allowed; }
|
||||
.keep-task-progress { display:flex; align-items:center; gap:.3rem; min-width:0; }
|
||||
.keep-task-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; }
|
||||
.keep-task-save:hover { border-color:#6A5129; background:#6A5129; }
|
||||
.keep-task-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; }
|
||||
.keep-task-status.is-normal { border-color:rgba(83,121,62,.3); background:#f3f7ee; color:var(--vip-success, #53793E); }
|
||||
.keep-task-status.is-online_banking_abnormal { border-color:rgba(184,0,0,.3); background:#fff5f5; color:var(--vip-danger, #B80000); }
|
||||
.keep-task-status.is-limited { border-color:rgba(147,95,7,.32); background:#fff8eb; color:var(--vip-warning, #935F07); }
|
||||
.keep-task-status.is-business_deregistered { border-color:rgba(89,89,89,.28); background:#f3f1ec; color:var(--vip-content-muted, #595959); }
|
||||
.keep-task-current { display:inline-flex; align-items:center; justify-content:center; min-width:2rem; height:1.45rem; border-radius:999px; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:700; }
|
||||
.keep-column-resizer { position:absolute; top:0; right:0; z-index:6; width:.45rem; height:100%; cursor:col-resize; touch-action:none; }
|
||||
.keep-column-resizer::after { content:""; position:absolute; top:20%; bottom:20%; left:50%; width:1px; background:transparent; transition:background .15s ease; }
|
||||
.keep-column-resizer:hover::after,
|
||||
.keep-column-resizer.is-active::after { background:var(--vip-content-accent-dark, #856632); }
|
||||
.keep-column-resizer:focus { outline:none; }
|
||||
.keep-column-resizer:focus-visible::after { width:2px; background:var(--vip-content-accent-dark, #856632); }
|
||||
body.is-resizing-keep-column { cursor:col-resize; user-select:none; }
|
||||
.keep-task-empty { padding:3rem 1rem; color:var(--vip-content-muted, #595959); text-align:center; }
|
||||
.keep-task-page.is-paginating .keep-task-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; }
|
||||
.keep-task-page__status { color:var(--vip-content-muted, #595959); font-size:.68rem; }
|
||||
@media (max-width:1100px) { .keep-task-filter-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width:700px) { .keep-task-filter-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width:560px) { .keep-task-page { margin:1rem .75rem 1.5rem; } .keep-task-filter-grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
<section class="keep-task-page" aria-labelledby="keep-task-title">
|
||||
<header class="keep-task-hero">
|
||||
<div>
|
||||
<h2 id="keep-task-title">不保留账户处理待办</h2>
|
||||
<p>账户总表中“账户是否保留”为“否”的账户会自动进入本清单;不与其他页面的待办事项合并。</p>
|
||||
</div>
|
||||
<a class="keep-task-back" href="{{ $backUrl }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">arrow_back</span>
|
||||
返回账户总表
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="keep-task-summary">
|
||||
<div class="keep-task-card"><span>待处理账户</span><b>{{ number_format((int) ($summary['count'] ?? 0)) }}</b></div>
|
||||
<div class="keep-task-card"><span>进行中</span><b>{{ number_format((int) ($summary['in_progress'] ?? 0)) }}</b></div>
|
||||
<div class="keep-task-card"><span>待开始</span><b>{{ number_format((int) ($summary['not_started'] ?? 0)) }}</b></div>
|
||||
<div class="keep-task-card"><span>已完成</span><b>{{ number_format((int) ($summary['completed'] ?? 0)) }}</b></div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="keep-task-alert" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span>{{ session('success') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="keep-task-alert is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error</span>{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form class="keep-task-toolbar" method="GET" action="{{ route('business.financial-ledger.unretained-accounts') }}" aria-label="筛选不保留账户处理待办">
|
||||
@if ($sortField !== '')
|
||||
<input type="hidden" name="keep_sort" value="{{ $sortField }}">
|
||||
<input type="hidden" name="keep_direction" value="{{ $sortDirection }}">
|
||||
@endif
|
||||
<div class="keep-task-filter-grid">
|
||||
<label class="keep-task-field">
|
||||
<span>账户简称</span>
|
||||
<input name="keep_short" value="{{ $filters['keep_short'] }}" placeholder="输入账户简称">
|
||||
</label>
|
||||
<label class="keep-task-field">
|
||||
<span>账户类型</span>
|
||||
<select name="keep_type">
|
||||
<option value="">全部类型</option>
|
||||
@foreach ($accountTypeOptions as $accountType)
|
||||
<option value="{{ $accountType }}" @selected($filters['keep_type'] === $accountType)>{{ $accountType }}</option>
|
||||
@endforeach
|
||||
<option value="__empty__" @selected($filters['keep_type'] === '__empty__')>未填写</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="keep-task-field">
|
||||
<span>账户状态</span>
|
||||
<select name="keep_status">
|
||||
<option value="">全部状态</option>
|
||||
@foreach ($accountStatusOptions as $statusCode => $statusLabel)
|
||||
<option value="{{ $statusCode }}" @selected($filters['keep_status'] === $statusCode)>{{ $statusLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="keep-task-field">
|
||||
<span>办理方式</span>
|
||||
<select name="keep_method">
|
||||
<option value="">全部方式</option>
|
||||
@foreach ($handlingMethods as $methodValue => $methodLabel)
|
||||
<option value="{{ $methodValue }}" @selected($filters['keep_method'] === $methodValue)>{{ $methodLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="keep-task-field">
|
||||
<span>责任人员</span>
|
||||
<select name="keep_owner">
|
||||
<option value="">全部人员</option>
|
||||
@foreach ($ownerOptions as $owner)
|
||||
<option value="{{ $owner }}" @selected($filters['keep_owner'] === $owner)>{{ $owner }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<label class="keep-task-field">
|
||||
<span>当前进度</span>
|
||||
<select name="keep_progress">
|
||||
<option value="">全部进度</option>
|
||||
@foreach ($completionStatuses as $progressValue => $progressLabel)
|
||||
<option value="{{ $progressValue }}" @selected($filters['keep_progress'] === $progressValue)>{{ $progressLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="keep-task-toolbar-actions">
|
||||
<button class="keep-task-button is-primary" type="submit">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
查询
|
||||
</button>
|
||||
<a class="keep-task-button" href="{{ route('business.financial-ledger.unretained-accounts') }}">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
重置
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@php
|
||||
$sortQuery = request()->query();
|
||||
unset($sortQuery['page']);
|
||||
$sortUrl = static fn (string $field): string => route(
|
||||
'business.financial-ledger.unretained-accounts',
|
||||
array_merge($sortQuery, [
|
||||
'keep_sort' => $field,
|
||||
'keep_direction' => $sortField === $field && $sortDirection === 'asc' ? 'desc' : 'asc',
|
||||
]),
|
||||
);
|
||||
@endphp
|
||||
|
||||
<p class="keep-task-table-hint">拖动表头右侧边界可调整列宽,双击边界恢复默认</p>
|
||||
<div class="keep-task-table-wrap">
|
||||
<table class="keep-task-table" data-keep-table data-column-storage-key="caiwu.financial-ledger.unretained-columns.v1.{{ auth()->id() }}.{{ company_id() }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-keep-column-key="company_name">公司名称</th>
|
||||
<th data-keep-column-key="account_short">账户简称</th>
|
||||
<th data-keep-column-key="account_type">账户类型</th>
|
||||
<th data-keep-column-key="account_status">账户状态</th>
|
||||
<th data-keep-column-key="account_no">账号</th>
|
||||
<th data-keep-column-key="bank_name">开户行</th>
|
||||
<th data-keep-column-key="bank_city_branch">开户城市/网点</th>
|
||||
<th data-keep-column-key="account_should_keep">账户是否保留</th>
|
||||
<th data-keep-column-key="handling_method">办理方式</th>
|
||||
<th data-keep-column-key="owner_name">责任人员</th>
|
||||
<th data-keep-column-key="started_at" @if ($sortField === 'started_at') aria-sort="{{ $sortDirection === 'asc' ? 'ascending' : 'descending' }}" @endif>
|
||||
<a class="keep-task-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-keep-column-key="completed_at" @if ($sortField === 'completed_at') aria-sort="{{ $sortDirection === 'asc' ? 'ascending' : 'descending' }}" @endif>
|
||||
<a class="keep-task-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-keep-column-key="current_progress">当前进度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-keep-tbody>
|
||||
@forelse ($rows as $row)
|
||||
<tr data-keep-account="{{ $row->account_short }}">
|
||||
<td>
|
||||
<form id="keep-task-form-{{ $loop->index }}" method="POST" action="{{ route('business.financial-ledger.accounts.keep-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="keep-task-status is-{{ $row->account_status }}">{{ $accountStatusOptions[$row->account_status] ?? '正常' }}</span></td>
|
||||
<td>{{ $row->account_no ?: '未填写' }}</td>
|
||||
<td>{{ $row->bank_name ?: '未填写' }}</td>
|
||||
<td>{{ $row->bank_city_branch ?: '未填写' }}</td>
|
||||
<td><span class="keep-task-current">否</span></td>
|
||||
<td>
|
||||
<select class="keep-task-input" form="keep-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="keep-task-input" form="keep-task-form-{{ $loop->index }}" name="owner_name" value="{{ $row->todo_owner_name }}" placeholder="填写责任人员" @disabled(! $canEdit)></td>
|
||||
<td><input class="keep-task-input" form="keep-task-form-{{ $loop->index }}" type="datetime-local" name="started_at" value="{{ $dateTimeValue($row->todo_started_at) }}" @disabled(! $canEdit)></td>
|
||||
<td><input class="keep-task-input" form="keep-task-form-{{ $loop->index }}" type="datetime-local" name="completed_at" value="{{ $dateTimeValue($row->todo_completed_at) }}" @disabled(! $canEdit)></td>
|
||||
<td>
|
||||
<div class="keep-task-progress">
|
||||
<select class="keep-task-input" form="keep-task-form-{{ $loop->index }}" name="current_progress" aria-label="当前进度" @disabled(! $canEdit)>
|
||||
@foreach ($completionStatuses as $progressValue => $progressLabel)
|
||||
<option value="{{ $progressValue }}" @selected(($row->todo_current_progress ?? 'not_started') === $progressValue)>{{ $progressLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if ($canEdit)
|
||||
<button class="keep-task-save" form="keep-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>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="13" class="keep-task-empty">当前没有需要处理的不保留账户</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($rows->total() > 0)
|
||||
<div class="ledger-pagination" data-keep-pagination>
|
||||
<span>共 {{ number_format($rows->total()) }} 条 · 每页 {{ number_format($rows->perPage()) }} 条</span>
|
||||
<div class="ledger-pagination__controls">
|
||||
<span class="keep-task-page__status" data-keep-page-status role="status" aria-live="polite" hidden></span>
|
||||
<nav class="ledger-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-keep-table]');
|
||||
if (!table) return;
|
||||
const headers = [...table.querySelectorAll('thead th[data-keep-column-key]')];
|
||||
if (headers.length === 0) return;
|
||||
|
||||
const storageKey = table.dataset.columnStorageKey || 'caiwu.financial-ledger.unretained-columns';
|
||||
const minimumWidth = 56;
|
||||
const maximumWidth = 720;
|
||||
const clampWidth = (value) => Math.min(maximumWidth, Math.max(minimumWidth, Math.round(Number(value) || 0)));
|
||||
const keepColumnDefaultWidths = Object.freeze({
|
||||
company_name: 170,
|
||||
account_short: 92,
|
||||
account_type: 72,
|
||||
account_status: 78,
|
||||
account_no: 180,
|
||||
bank_name: 250,
|
||||
bank_city_branch: 180,
|
||||
account_should_keep: 100,
|
||||
handling_method: 95,
|
||||
owner_name: 105,
|
||||
started_at: 178,
|
||||
completed_at: 178,
|
||||
current_progress: 130,
|
||||
});
|
||||
const defaults = new Map(headers.map((header) => {
|
||||
const key = header.dataset.keepColumnKey;
|
||||
return [key, clampWidth(keepColumnDefaultWidths[key] ?? 120)];
|
||||
}));
|
||||
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.keepColumnGroup = 'true';
|
||||
headers.forEach((header) => {
|
||||
const column = document.createElement('col');
|
||||
column.dataset.keepColumnKey = header.dataset.keepColumnKey;
|
||||
group.appendChild(column);
|
||||
});
|
||||
table.insertBefore(group, table.firstChild);
|
||||
|
||||
const applyWidths = () => {
|
||||
let totalWidth = 0;
|
||||
headers.forEach((header) => {
|
||||
const key = header.dataset.keepColumnKey;
|
||||
const width = clampWidth(widths.get(key) ?? defaults.get(key));
|
||||
const column = group.querySelector('col[data-keep-column-key="' + key + '"]');
|
||||
if (column) column.style.width = width + 'px';
|
||||
const resizer = header.querySelector('[data-keep-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.keepColumnKey;
|
||||
const link = header.querySelector('.keep-task-sort');
|
||||
const label = (link?.firstChild?.textContent || header.textContent).trim() || '未命名字段';
|
||||
const resizer = document.createElement('span');
|
||||
resizer.className = 'keep-column-resizer';
|
||||
resizer.dataset.keepColumnResizer = '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-keep-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-keep-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('.keep-task-page');
|
||||
if (!page) return;
|
||||
let pendingRequest = null;
|
||||
const tableWrap = () => page.querySelector('.keep-task-table-wrap');
|
||||
const setPageStatus = (message, isError = false, hideAfter = 0) => {
|
||||
const status = page.querySelector('[data-keep-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('[data-keep-pagination] .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-keep-tbody]');
|
||||
const currentPagination = page.querySelector('[data-keep-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-keep-tbody]');
|
||||
const nextPagination = nextDocument.querySelector('[data-keep-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, keepTaskAjax: 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>
|
||||
@ -705,10 +705,18 @@ Route::get('workspace/financial-operations/ledger/unavailable-balances', [\App\H
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.unavailable-balances');
|
||||
|
||||
Route::get('workspace/financial-operations/ledger/unretained-accounts', [\App\Http\Controllers\Business\FinanceLedger::class, 'unretainedAccountTasks'])
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.unretained-accounts');
|
||||
|
||||
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::post('workspace/financial-ledger/accounts/keep-todo', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountKeepTodo'])
|
||||
->middleware('business.page:financial-operations,ledger')
|
||||
->name('business.financial-ledger.accounts.keep-todo');
|
||||
|
||||
Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) {
|
||||
abort_unless($page === null || $page === 'ledger', 404);
|
||||
|
||||
|
||||
354
tests/Feature/Business/FinanceLedgerAccountKeepTodoTest.php
Normal file
354
tests/Feature/Business/FinanceLedgerAccountKeepTodoTest.php
Normal file
@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Auth\Role;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class FinanceLedgerAccountKeepTodoTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
}
|
||||
|
||||
private function ledgerUrl(): string
|
||||
{
|
||||
return route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'financial-operations',
|
||||
'page' => 'ledger',
|
||||
]);
|
||||
}
|
||||
|
||||
private function keepTasksUrl(array $parameters = []): string
|
||||
{
|
||||
return route('business.financial-ledger.unretained-accounts', array_merge([
|
||||
'company_id' => $this->company->id,
|
||||
], $parameters));
|
||||
}
|
||||
|
||||
private function keepTodoUrl(): string
|
||||
{
|
||||
return route('business.financial-ledger.accounts.keep-todo', [
|
||||
'company_id' => $this->company->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ledger_card_follows_account_count_and_links_to_independent_keep_tasks(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('fin_accounts')->insert([
|
||||
[
|
||||
'company_name' => '不保留测试公司甲',
|
||||
'account_short' => '显式不保留账户',
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => '否',
|
||||
],
|
||||
[
|
||||
'company_name' => '不保留测试公司乙',
|
||||
'account_short' => '规划不保留账户',
|
||||
'company_plan' => '待注销',
|
||||
'account_should_keep' => null,
|
||||
],
|
||||
[
|
||||
'company_name' => '不保留测试公司丙',
|
||||
'account_short' => '明确保留账户',
|
||||
'company_plan' => '待注销',
|
||||
'account_should_keep' => '是',
|
||||
],
|
||||
[
|
||||
'company_name' => '不保留测试公司丁',
|
||||
'account_short' => '规划保留账户',
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->get($this->ledgerUrl())->assertOk();
|
||||
$content = $response->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/<span>账户数<\/span><b>4<\/b><\/div>\s*<a[^>]+aria-label="查看不保留账户处理待办"[^>]*><span>不保留账户数<\/span><b>2<\/b><\/a>/u',
|
||||
$content,
|
||||
);
|
||||
$response->assertSee('href="' . $this->keepTasksUrl() . '"', false);
|
||||
}
|
||||
|
||||
public function test_keep_tasks_page_uses_required_columns_filters_and_retention_rule(): void
|
||||
{
|
||||
$connection = DB::connection('business_mirror');
|
||||
$connection->table('fin_accounts')->insert([
|
||||
[
|
||||
'company_name' => '展示测试公司甲',
|
||||
'account_short' => '显式不保留账户',
|
||||
'account_type' => '公户',
|
||||
'account_no' => '6222000000000001',
|
||||
'bank_name' => '展示测试银行甲',
|
||||
'bank_city_branch' => '上海 / 测试支行',
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => '否',
|
||||
],
|
||||
[
|
||||
'company_name' => '展示测试公司乙',
|
||||
'account_short' => '规划不保留账户',
|
||||
'account_type' => '个人卡',
|
||||
'account_no' => null,
|
||||
'bank_name' => null,
|
||||
'bank_city_branch' => null,
|
||||
'company_plan' => '待注销',
|
||||
'account_should_keep' => null,
|
||||
],
|
||||
[
|
||||
'company_name' => '展示测试公司丙',
|
||||
'account_short' => '明确保留账户',
|
||||
'account_type' => '支付宝',
|
||||
'account_no' => null,
|
||||
'bank_name' => null,
|
||||
'bank_city_branch' => null,
|
||||
'company_plan' => '待注销',
|
||||
'account_should_keep' => '是',
|
||||
],
|
||||
[
|
||||
'company_name' => '展示测试公司丁',
|
||||
'account_short' => '规划保留账户',
|
||||
'account_type' => '网商银行',
|
||||
'account_no' => null,
|
||||
'bank_name' => null,
|
||||
'bank_city_branch' => null,
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => null,
|
||||
],
|
||||
]);
|
||||
$connection->table('fin_account_statuses')->insert([
|
||||
'account_short' => '显式不保留账户',
|
||||
'status' => 'limited',
|
||||
'updated_by' => 'Test User',
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->get($this->keepTasksUrl())->assertOk();
|
||||
$content = $response->getContent();
|
||||
|
||||
$response
|
||||
->assertSee('不保留账户处理待办')
|
||||
->assertSee('不与其他页面的待办事项合并')
|
||||
->assertSee('data-keep-account="显式不保留账户"', false)
|
||||
->assertSee('data-keep-account="规划不保留账户"', false)
|
||||
->assertDontSee('data-keep-account="明确保留账户"', false)
|
||||
->assertDontSee('data-keep-account="规划保留账户"', false)
|
||||
->assertSee('name="keep_short"', false)
|
||||
->assertSee('name="keep_type"', false)
|
||||
->assertSee('name="keep_status"', false)
|
||||
->assertSee('name="keep_method"', false)
|
||||
->assertSee('name="keep_owner"', false)
|
||||
->assertSee('name="keep_progress"', false)
|
||||
->assertSee('按开始时间排序')
|
||||
->assertSee('按完成时间排序')
|
||||
->assertSee('线上')
|
||||
->assertSee('线下')
|
||||
->assertSee('已完成')
|
||||
->assertSee('进行中')
|
||||
->assertSee('待开始')
|
||||
->assertSee('data-keep-tbody', false)
|
||||
->assertSee('data-keep-pagination', false)
|
||||
->assertSee('拖动表头右侧边界可调整列宽')
|
||||
->assertSee('cursor:col-resize', false)
|
||||
->assertSee('position:sticky', false)
|
||||
->assertSee('currentBody.replaceWith', false)
|
||||
->assertSee('history.replaceState', false)
|
||||
->assertSee('nextWrap.scrollTop = scrollTop', false);
|
||||
|
||||
preg_match_all('/<th data-keep-column-key="([^"]+)"/u', $content, $matches);
|
||||
$this->assertSame([
|
||||
'company_name',
|
||||
'account_short',
|
||||
'account_type',
|
||||
'account_status',
|
||||
'account_no',
|
||||
'bank_name',
|
||||
'bank_city_branch',
|
||||
'account_should_keep',
|
||||
'handling_method',
|
||||
'owner_name',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'current_progress',
|
||||
], $matches[1]);
|
||||
}
|
||||
|
||||
public function test_keep_tasks_filters_sort_and_ajax_pagination_work(): void
|
||||
{
|
||||
$connection = DB::connection('business_mirror');
|
||||
$connection->table('fin_accounts')->insert([
|
||||
[
|
||||
'company_name' => '筛选公司甲',
|
||||
'account_short' => '筛选账户甲',
|
||||
'account_type' => '公户',
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => '否',
|
||||
],
|
||||
[
|
||||
'company_name' => '筛选公司乙',
|
||||
'account_short' => '筛选账户乙',
|
||||
'account_type' => '个人卡',
|
||||
'company_plan' => '待注销',
|
||||
'account_should_keep' => null,
|
||||
],
|
||||
]);
|
||||
$connection->table('fin_account_statuses')->insert([
|
||||
'account_short' => '筛选账户甲',
|
||||
'status' => 'limited',
|
||||
'updated_by' => 'Test User',
|
||||
]);
|
||||
$connection->table('fin_account_keep_todos')->insert([
|
||||
[
|
||||
'account_short' => '筛选账户甲',
|
||||
'handling_method' => 'online',
|
||||
'owner_name' => '甲负责人',
|
||||
'started_at' => '2026-10-21 09:00:00',
|
||||
'current_progress' => 'in_progress',
|
||||
],
|
||||
[
|
||||
'account_short' => '筛选账户乙',
|
||||
'handling_method' => 'offline',
|
||||
'owner_name' => '乙负责人',
|
||||
'started_at' => '2026-10-22 09:00:00',
|
||||
'current_progress' => 'not_started',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($this->keepTasksUrl([
|
||||
'keep_short' => '筛选账户甲',
|
||||
'keep_type' => '公户',
|
||||
'keep_status' => 'limited',
|
||||
'keep_method' => 'online',
|
||||
'keep_owner' => '甲负责人',
|
||||
'keep_progress' => 'in_progress',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('data-keep-account="筛选账户甲"', false)
|
||||
->assertDontSee('data-keep-account="筛选账户乙"', false)
|
||||
->assertSee('value="limited" selected', false)
|
||||
->assertSee('value="online" selected', false)
|
||||
->assertSee('value="in_progress" selected', false);
|
||||
|
||||
$sortResponse = $this->loginAs()->get($this->keepTasksUrl([
|
||||
'keep_sort' => 'started_at',
|
||||
'keep_direction' => 'desc',
|
||||
]))->assertOk()->assertSee('aria-sort="descending"', false);
|
||||
preg_match_all('/data-keep-account="([^"]+)"/u', $sortResponse->getContent(), $sortedAccounts);
|
||||
$this->assertSame(['筛选账户乙', '筛选账户甲'], $sortedAccounts[1]);
|
||||
|
||||
$pageRows = [];
|
||||
for ($index = 1; $index <= 51; $index++) {
|
||||
$pageRows[] = [
|
||||
'company_name' => '分页公司',
|
||||
'account_short' => 'keep-page-' . str_pad((string) $index, 2, '0', STR_PAD_LEFT),
|
||||
'account_should_keep' => '否',
|
||||
];
|
||||
}
|
||||
$connection->table('fin_accounts')->insert($pageRows);
|
||||
|
||||
$this->loginAs()->get($this->keepTasksUrl(['keep_short' => 'keep-page-']))
|
||||
->assertOk()
|
||||
->assertSee('1 / 2')
|
||||
->assertSee('aria-label="下一页"', false)
|
||||
->assertSee('data-keep-pagination', false);
|
||||
$this->loginAs()->get($this->keepTasksUrl(['keep_short' => 'keep-page-', 'page' => 2]))
|
||||
->assertOk()
|
||||
->assertSee('2 / 2')
|
||||
->assertSee('data-keep-account="keep-page-51"', false);
|
||||
}
|
||||
|
||||
public function test_keep_todo_save_is_isolated_from_abnormal_tasks_and_permission_protected(): void
|
||||
{
|
||||
$connection = DB::connection('business_mirror');
|
||||
$connection->table('fin_accounts')->insert([
|
||||
[
|
||||
'company_name' => '待办保存公司甲',
|
||||
'account_short' => '待办不保留账户',
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => '否',
|
||||
],
|
||||
[
|
||||
'company_name' => '待办保存公司乙',
|
||||
'account_short' => '规划不保留待办账户',
|
||||
'company_plan' => '待注销',
|
||||
'account_should_keep' => null,
|
||||
],
|
||||
[
|
||||
'company_name' => '待办保存公司丙',
|
||||
'account_short' => '待办保留账户',
|
||||
'company_plan' => '保留',
|
||||
'account_should_keep' => '是',
|
||||
],
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'account_short' => '待办不保留账户',
|
||||
'handling_method' => 'online',
|
||||
'owner_name' => '待办负责人',
|
||||
'started_at' => '2026-10-21T09:00',
|
||||
'completed_at' => '2026-10-21T11:30',
|
||||
'current_progress' => 'in_progress',
|
||||
];
|
||||
$this->loginAs()->post($this->keepTodoUrl(), $payload)->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('fin_account_keep_todos', [
|
||||
'account_short' => '待办不保留账户',
|
||||
'handling_method' => 'online',
|
||||
'owner_name' => '待办负责人',
|
||||
'current_progress' => 'in_progress',
|
||||
'updated_by' => 'Test User',
|
||||
], 'business_mirror');
|
||||
$this->assertDatabaseCount('fin_account_todos', 0, 'business_mirror');
|
||||
|
||||
$this->loginAs()->post($this->keepTodoUrl(), array_merge($payload, [
|
||||
'current_progress' => 'completed',
|
||||
]))->assertRedirect();
|
||||
$this->assertDatabaseCount('fin_account_keep_todos', 1, 'business_mirror');
|
||||
$this->assertDatabaseHas('fin_account_keep_todos', [
|
||||
'account_short' => '待办不保留账户',
|
||||
'current_progress' => 'completed',
|
||||
], 'business_mirror');
|
||||
|
||||
$this->loginAs()->post($this->keepTodoUrl(), [
|
||||
'account_short' => '规划不保留待办账户',
|
||||
'current_progress' => 'not_started',
|
||||
])->assertRedirect();
|
||||
$this->assertDatabaseHas('fin_account_keep_todos', [
|
||||
'account_short' => '规划不保留待办账户',
|
||||
'current_progress' => 'not_started',
|
||||
], 'business_mirror');
|
||||
|
||||
$this->withExceptionHandling()->loginAs()->post($this->keepTodoUrl(), [
|
||||
'account_short' => '待办保留账户',
|
||||
'current_progress' => 'not_started',
|
||||
])->assertStatus(422);
|
||||
|
||||
$viewer = user_model_class()::create([
|
||||
'name' => '不保留账户只读用户',
|
||||
'email' => 'keep-viewer-' . uniqid() . '@example.com',
|
||||
'password' => 'local-test-password',
|
||||
'locale' => 'zh-CN',
|
||||
'enabled' => 1,
|
||||
'landing_page' => 'dashboard',
|
||||
'created_from' => 'core::test',
|
||||
]);
|
||||
$viewer->companies()->attach($this->company->id);
|
||||
$viewer->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
|
||||
$viewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
|
||||
$this->loginAs($viewer)->get($this->keepTasksUrl())
|
||||
->assertOk()
|
||||
->assertSee('disabled', false)
|
||||
->assertDontSee('title="保存待办"', false);
|
||||
$this->withExceptionHandling()->loginAs($viewer)->post($this->keepTodoUrl(), $payload)
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user