Changes to documents able to edit them now, also support for auto mail attechemnts

This commit is contained in:
Simon Pocrnjič 2025-10-18 19:04:10 +02:00
parent 761799bdbe
commit 3b1a24287a
19 changed files with 820 additions and 108 deletions

View File

@ -252,14 +252,19 @@ public function storeActivity(ClientCase $clientCase, Request $request)
'decision_id' => 'exists:\App\Models\Decision,id', 'decision_id' => 'exists:\App\Models\Decision,id',
'contract_uuid' => 'nullable|uuid', 'contract_uuid' => 'nullable|uuid',
'send_auto_mail' => 'sometimes|boolean', 'send_auto_mail' => 'sometimes|boolean',
'attachment_document_ids' => 'sometimes|array',
'attachment_document_ids.*' => 'integer',
]); ]);
// Map contract_uuid to contract_id within the same client case, if provided // Map contract_uuid to contract_id within the same client case, if provided
$contractId = null; $contractId = null;
if (! empty($attributes['contract_uuid'])) { if (! empty($attributes['contract_uuid'])) {
$contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail(['id']); $contract = Contract::withTrashed()
->where('uuid', $attributes['contract_uuid'])
->where('client_case_id', $clientCase->id)
->first();
if ($contract) { if ($contract) {
// Archived contracts are now allowed: link activity regardless of active flag // Archived contracts are allowed: link activity regardless of active flag
$contractId = $contract->id; $contractId = $contract->id;
} }
} }
@ -284,7 +289,22 @@ public function storeActivity(ClientCase $clientCase, Request $request)
try { try {
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']); $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag); // Filter attachments to those belonging to the selected contract
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
->filter()
->map(fn ($v) => (int) $v)
->values();
$validAttachmentIds = collect();
if ($attachmentIds->isNotEmpty() && $contractId) {
$validAttachmentIds = \App\Models\Document::query()
->where('documentable_type', \App\Models\Contract::class)
->where('documentable_id', $contractId)
->whereIn('id', $attachmentIds)
->pluck('id');
}
$result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [
'attachment_ids' => $validAttachmentIds->all(),
]);
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) { if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
// If template requires contract and user attempted to send, surface a validation message // If template requires contract and user attempted to send, surface a validation message
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.'); return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
@ -331,10 +351,6 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re
{ {
$contract = Contract::where('uuid', $uuid)->firstOrFail(); $contract = Contract::where('uuid', $uuid)->firstOrFail();
\DB::transaction(function () use ($contract) {
$contract->delete();
});
// Preserve segment filter if present // Preserve segment filter if present
$segment = request('segment'); $segment = request('segment');
@ -484,6 +500,89 @@ public function storeDocument(ClientCase $clientCase, Request $request)
return back()->with('success', 'Document uploaded.'); return back()->with('success', 'Document uploaded.');
} }
public function updateDocument(ClientCase $clientCase, Document $document, Request $request)
{
// Validate that the document being updated is scoped to this case (or one of its contracts).
// Ensure the document belongs to this case or its contracts
$belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id;
$belongsToContractOfCase = false;
if ($document->documentable_type === Contract::class) {
$belongsToContractOfCase = Contract::withTrashed()
->where('id', $document->documentable_id)
->where('client_case_id', $clientCase->id)
->exists();
}
if (! ($belongsToCase || $belongsToContractOfCase)) {
logger()->warning('Document update 404: document not in scope of client case or its contracts', [
'doc_id' => $document->id,
'doc_uuid' => $document->uuid,
'doc_type' => $document->documentable_type,
'doc_doc_id' => $document->documentable_id,
'route_case_id' => $clientCase->id,
'route_case_uuid' => $clientCase->uuid,
]);
abort(404);
}
// Strictly validate that provided contract_uuid (when present) belongs to THIS client case.
// If a different case's contract UUID is provided, return a validation error (422) instead of falling back.
$validated = $request->validate([
'name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'is_public' => 'sometimes|boolean',
// Optional reassignment to a contract within the same case
// Note: empty string explicitly means "move back to case" when the key exists in the request.
'contract_uuid' => [
'nullable',
'uuid',
\Illuminate\Validation\Rule::exists('contracts', 'uuid')->where(function ($q) use ($clientCase, $request) {
// Allow empty string if key exists (handled later) by skipping exists check when empty
$incoming = $request->input('contract_uuid');
if (is_null($incoming) || $incoming === '') {
// Return a condition that always matches something harmless; exists rule is ignored in this case
return $q; // no-op, DBAL will still run but empty will be caught by nullable
}
return $q->where('client_case_id', $clientCase->id);
}),
],
]);
// Basic attribute updates
$document->name = $validated['name'] ?? $document->name;
if (array_key_exists('description', $validated)) {
$document->description = $validated['description'];
}
if (array_key_exists('is_public', $validated)) {
$document->is_public = (bool) $validated['is_public'];
}
// Reassign to contract or back to case IF the key is present in the payload (explicit intent).
if ($request->exists('contract_uuid')) {
$incoming = $request->input('contract_uuid');
if ($incoming === '' || is_null($incoming)) {
// Explicitly move relation back to the case
$document->documentable_type = ClientCase::class;
$document->documentable_id = $clientCase->id;
} else {
// Safe to resolve within this case due to the validation rule above
$target = $clientCase->contracts()->where('uuid', $incoming)->firstOrFail(['id', 'uuid', 'active']);
if (! $target->active) {
return back()->with('warning', __('contracts.document_not_allowed_archived'));
}
$document->documentable_type = Contract::class;
$document->documentable_id = $target->id;
}
}
$document->save();
// Refresh documents list on page
return back()->with('success', __('Document updated.'));
}
public function viewDocument(ClientCase $clientCase, Document $document, Request $request) public function viewDocument(ClientCase $clientCase, Document $document, Request $request)
{ {
// Ensure the document belongs to this client case or its contracts // Ensure the document belongs to this client case or its contracts
@ -1154,7 +1253,7 @@ public function show(ClientCase $clientCase)
$contractDocs = collect(); $contractDocs = collect();
} else { } else {
$contractDocs = Document::query() $contractDocs = Document::query()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at']) ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->where('documentable_type', Contract::class) ->where('documentable_type', Contract::class)
->whereIn('documentable_id', $contractIds) ->whereIn('documentable_id', $contractIds)
->orderByDesc('created_at') ->orderByDesc('created_at')
@ -1170,7 +1269,7 @@ public function show(ClientCase $clientCase)
} }
$caseDocs = $case->documents() $caseDocs = $case->documents()
->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at']) ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public'])
->orderByDesc('created_at') ->orderByDesc('created_at')
->limit(200) ->limit(200)
->get() ->get()
@ -1243,7 +1342,7 @@ function ($p) {
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id'); $q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
}, },
'decisions.emailTemplate' => function ($q) { 'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types'); $q->select('id', 'name', 'entity_types', 'allow_attachments');
}, },
]) ])
->get(['id', 'name', 'color_tag', 'segment_id']), ->get(['id', 'name', 'color_tag', 'segment_id']),

View File

@ -8,12 +8,12 @@
use App\Models\Document; use App\Models\Document;
use App\Models\DocumentTemplate; use App\Models\DocumentTemplate;
use App\Services\Documents\TokenValueResolver; use App\Services\Documents\TokenValueResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Illuminate\Http\RedirectResponse;
class ContractDocumentGenerationController extends Controller class ContractDocumentGenerationController extends Controller
{ {
@ -21,7 +21,8 @@ public function __invoke(Request $request, Contract $contract): Response|Redirec
{ {
// Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON // Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON
$isInertia = (bool) $request->header('X-Inertia'); $isInertia = (bool) $request->header('X-Inertia');
$wantsJson = ! $isInertia && ($request->expectsJson() || $request->wantsJson()); // For non-Inertia POSTs, prefer JSON responses by default (including tests)
$wantsJson = ! $isInertia;
if (Gate::denies('read')) { // baseline read permission required to generate if (Gate::denies('read')) { // baseline read permission required to generate
abort(403); abort(403);
} }

View File

@ -175,7 +175,17 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request)
'documents' => $documents, 'documents' => $documents,
'types' => $types, 'types' => $types,
'account_types' => \App\Models\AccountType::all(), 'account_types' => \App\Models\AccountType::all(),
'actions' => \App\Models\Action::with('decisions')->get(), // Provide decisions with linked email template metadata (entity_types, allow_attachments)
'actions' => \App\Models\Action::query()
->with([
'decisions' => function ($q) {
$q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id');
},
'decisions.emailTemplate' => function ($q) {
$q->select('id', 'name', 'entity_types', 'allow_attachments');
},
])
->get(['id', 'name', 'color_tag', 'segment_id']),
'activities' => $activities, 'activities' => $activities,
'completed_mode' => (bool) $request->boolean('completed'), 'completed_mode' => (bool) $request->boolean('completed'),
]); ]);

View File

@ -21,6 +21,7 @@ public function rules(): array
'text_template' => ['nullable', 'string'], 'text_template' => ['nullable', 'string'],
'entity_types' => ['nullable', 'array'], 'entity_types' => ['nullable', 'array'],
'entity_types.*' => ['string', 'in:client,client_case,contract,person'], 'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'], 'active' => ['boolean'],
]; ];
} }

View File

@ -23,6 +23,7 @@ public function rules(): array
'text_template' => ['nullable', 'string'], 'text_template' => ['nullable', 'string'],
'entity_types' => ['nullable', 'array'], 'entity_types' => ['nullable', 'array'],
'entity_types.*' => ['string', 'in:client,client_case,contract,person'], 'entity_types.*' => ['string', 'in:client,client_case,contract,person'],
'allow_attachments' => ['sometimes', 'boolean'],
'active' => ['boolean'], 'active' => ['boolean'],
]; ];
} }

View File

@ -17,12 +17,14 @@ class EmailTemplate extends Model
'html_template', 'html_template',
'text_template', 'text_template',
'entity_types', 'entity_types',
'allow_attachments',
'active', 'active',
]; ];
protected $casts = [ protected $casts = [
'active' => 'boolean', 'active' => 'boolean',
'entity_types' => 'array', 'entity_types' => 'array',
'allow_attachments' => 'boolean',
]; ];
public function documents(): MorphMany public function documents(): MorphMany

View File

@ -22,7 +22,7 @@ public function __construct(public EmailTemplateRenderer $renderer) {}
* Attempt to queue an auto mail for the given activity based on its decision/template. * Attempt to queue an auto mail for the given activity based on its decision/template.
* Returns array with either ['queued' => true, 'log_id' => int] or ['skipped' => 'reason']. * Returns array with either ['queued' => true, 'log_id' => int] or ['skipped' => 'reason'].
*/ */
public function maybeQueue(Activity $activity, bool $sendFlag = true): array public function maybeQueue(Activity $activity, bool $sendFlag = true, array $options = []): array
{ {
$decision = $activity->decision; $decision = $activity->decision;
if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) { if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) {
@ -114,6 +114,24 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true): array
if (count($recipients) > 1) { if (count($recipients) > 1) {
$log->to_email = null; $log->to_email = null;
} }
// Resolve and attach selected documents as attachments metadata (id, name, path, mime, size)
$attachmentIds = collect($options['attachment_ids'] ?? [])->filter()->map(fn ($v) => (int) $v)->values();
if ($attachmentIds->isNotEmpty()) {
$docs = \App\Models\Document::query()
->whereIn('id', $attachmentIds)
->get(['id', 'disk', 'path', 'original_name', 'name', 'mime_type', 'size']);
$log->attachments = $docs->map(function ($d) {
return [
'id' => $d->id,
'disk' => $d->disk ?: 'public',
'path' => $d->path,
'name' => $d->original_name ?: ($d->name ?: basename($d->path)),
'mime' => $d->mime_type ?: 'application/octet-stream',
'size' => $d->size,
];
})->values()->all();
}
$log->save(); $log->save();
$log->body()->create([ $log->body()->create([

View File

@ -156,7 +156,7 @@ public function sendFromLog(EmailLog $log): array
$senderBcc = null; $senderBcc = null;
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
// Check duplicates against toList // Check duplicates against toList
$lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) { if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$senderBcc = $fromAddr; $senderBcc = $fromAddr;
$email->bcc(new Address($senderBcc)); $email->bcc(new Address($senderBcc));
@ -175,6 +175,26 @@ public function sendFromLog(EmailLog $log): array
$email->replyTo($log->reply_to); $email->replyTo($log->reply_to);
} }
// Attach files if present on the log
$attachments = (array) ($log->attachments ?? []);
foreach ($attachments as $att) {
try {
$disk = $att['disk'] ?? 'public';
$path = $att['path'] ?? null;
if (! $path) {
continue;
}
$name = $att['name'] ?? basename($path);
$mime = $att['mime'] ?? 'application/octet-stream';
$full = \Storage::disk($disk)->path($path);
if (is_file($full)) {
$email->attachFromPath($full, $name, $mime);
}
} catch (\Throwable $e) {
// ignore individual attachment failures; continue sending
}
}
$mailer->send($email); $mailer->send($email);
// Save log if we modified BCC // Save log if we modified BCC
if (! empty($log->getAttribute('bcc'))) { if (! empty($log->getAttribute('bcc'))) {
@ -205,7 +225,7 @@ public function sendFromLog(EmailLog $log): array
// BCC the sender mailbox if resolvable and not already in To // BCC the sender mailbox if resolvable and not already in To
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? '')); $fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
$lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) { if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$message->bcc($fromAddr); $message->bcc($fromAddr);
$log->bcc = [$fromAddr]; $log->bcc = [$fromAddr];
@ -240,7 +260,7 @@ public function sendFromLog(EmailLog $log): array
// BCC the sender mailbox if resolvable and not already in To // BCC the sender mailbox if resolvable and not already in To
$fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? '')); $fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? ''));
if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) {
$lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email]));
if (! in_array(strtolower($fromAddr), $lowerTo, true)) { if (! in_array(strtolower($fromAddr), $lowerTo, true)) {
$message->bcc($fromAddr); $message->bcc($fromAddr);
$log->bcc = [$fromAddr]; $log->bcc = [$fromAddr];

View File

@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Document>
*/
class DocumentFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$ext = $this->faker->randomElement(['pdf', 'txt', 'png', 'jpeg']);
$nameBase = $this->faker->words(3, true);
$fileName = $nameBase.'.'.$ext;
return [
'name' => $nameBase,
'description' => $this->faker->optional()->sentence(),
'user_id' => User::factory(),
'disk' => 'public',
'path' => 'cases/'.$this->faker->uuid().'/documents/'.$fileName,
'file_name' => $fileName,
'original_name' => $fileName,
'extension' => $ext,
'mime_type' => match ($ext) {
'pdf' => 'application/pdf',
'txt' => 'text/plain',
'png' => 'image/png',
'jpeg' => 'image/jpeg',
default => 'application/octet-stream',
},
'size' => $this->faker->numberBetween(1000, 1000000),
'checksum' => null,
'is_public' => $this->faker->boolean(10),
];
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('email_templates', function (Blueprint $table) {
if (! Schema::hasColumn('email_templates', 'allow_attachments')) {
$table->boolean('allow_attachments')->default(false)->after('entity_types');
}
});
}
public function down(): void
{
Schema::table('email_templates', function (Blueprint $table) {
if (Schema::hasColumn('email_templates', 'allow_attachments')) {
$table->dropColumn('allow_attachments');
}
});
}
};

View File

@ -0,0 +1,96 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import { useForm } from '@inertiajs/vue3'
import { computed, watch } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
client_case_uuid: { type: String, required: true },
document: { type: Object, default: null },
contracts: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'saved'])
const form = useForm({
name: '',
description: '',
is_public: false,
contract_uuid: null,
})
watch(
() => props.document,
(d) => {
if (!d) return
form.name = d.name || d.original_name || ''
form.description = d.description || ''
form.is_public = !!d.is_public
// Pre-fill contract selection if this doc belongs to a contract
const isContract = (d?.documentable_type || '').toLowerCase().includes('contract')
form.contract_uuid = isContract ? d.contract_uuid || null : null
},
{ immediate: true }
)
const submit = () => {
if (!props.document) return
form.patch(
route('clientCase.document.update', {
client_case: props.client_case_uuid,
document: props.document.uuid,
}),
{
preserveScroll: true,
onSuccess: () => {
emit('saved')
emit('close')
},
}
)
}
const contractOptions = computed(() => {
return props.contracts || []
})
</script>
<template>
<DialogModal :show="show" @close="$emit('close')">
<template #title>Uredi dokument</template>
<template #content>
<div class="space-y-4">
<div>
<InputLabel for="docName" value="Ime" />
<TextInput id="docName" v-model="form.name" class="mt-1 block w-full" />
<div v-if="form.errors.name" class="text-sm text-red-600 mt-1">{{ form.errors.name }}</div>
</div>
<div>
<InputLabel for="docDesc" value="Opis" />
<TextInput id="docDesc" v-model="form.description" class="mt-1 block w-full" />
<div v-if="form.errors.description" class="text-sm text-red-600 mt-1">{{ form.errors.description }}</div>
</div>
<div class="flex items-center gap-2">
<input id="docPublic" type="checkbox" v-model="form.is_public" />
<InputLabel for="docPublic" value="Javno" />
</div>
<div>
<InputLabel for="docContract" value="Pogodba" />
<select id="docContract" v-model="form.contract_uuid" class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option :value="null"> Brez (dok. pri primeru)</option>
<option v-for="c in contractOptions" :key="c.uuid || c.id" :value="c.uuid">{{ c.reference || c.uuid }}</option>
</select>
<div v-if="form.errors.contract_uuid" class="text-sm text-red-600 mt-1">{{ form.errors.contract_uuid }}</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<button type="button" class="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200" @click="$emit('close')">Prekliči</button>
<button type="button" class="px-3 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="form.processing" @click="submit">Shrani</button>
</div>
</template>
</DialogModal>
</template>

View File

@ -45,7 +45,7 @@ const sourceLabel = (doc) => {
return "Primer"; return "Primer";
}; };
const emit = defineEmits(["view", "download", "delete"]); const emit = defineEmits(["view", "download", "delete", "edit"]);
const formatSize = (bytes) => { const formatSize = (bytes) => {
if (bytes == null) return "-"; if (bytes == null) return "-";
@ -318,6 +318,14 @@ function closeActions() {
</button> </button>
</template> </template>
<template #content> <template #content>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
@click="emit('edit', doc)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
<span>Uredi</span>
</button>
<button <button
type="button" type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2" class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"

View File

@ -18,6 +18,7 @@ const form = useForm({
html_template: props.template?.html_template ?? "", html_template: props.template?.html_template ?? "",
text_template: props.template?.text_template ?? "", text_template: props.template?.text_template ?? "",
entity_types: props.template?.entity_types ?? ["client", "contract"], entity_types: props.template?.entity_types ?? ["client", "contract"],
allow_attachments: props.template?.allow_attachments ?? false,
active: props.template?.active ?? true, active: props.template?.active ?? true,
}); });
@ -1065,6 +1066,10 @@ watch(
> >
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<input id="allow_attachments" type="checkbox" v-model="form.allow_attachments" />
<label for="allow_attachments" class="text-sm text-gray-700">Dovoli priponke</label>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input id="active" type="checkbox" v-model="form.active" /> <input id="active" type="checkbox" v-model="form.active" />
<label for="active" class="text-sm text-gray-700">Aktivno</label> <label for="active" class="text-sm text-gray-700">Aktivno</label>

View File

@ -4,64 +4,85 @@ import BasicButton from "@/Components/buttons/BasicButton.vue";
import DialogModal from "@/Components/DialogModal.vue"; import DialogModal from "@/Components/DialogModal.vue";
import InputLabel from "@/Components/InputLabel.vue"; import InputLabel from "@/Components/InputLabel.vue";
import DatePickerField from "@/Components/DatePickerField.vue"; import DatePickerField from "@/Components/DatePickerField.vue";
import TextInput from "@/Components/TextInput.vue";
import CurrencyInput from "@/Components/CurrencyInput.vue"; import CurrencyInput from "@/Components/CurrencyInput.vue";
import { useForm } from "@inertiajs/vue3"; import { useForm, usePage } from "@inertiajs/vue3";
import { FwbTextarea } from "flowbite-vue"; import { FwbTextarea } from "flowbite-vue";
import { ref, watch, computed } from "vue"; import { ref, watch, computed } from "vue";
const props = defineProps({ const props = defineProps({
show: { show: { type: Boolean, default: false },
type: Boolean, client_case: { type: Object, required: true },
default: false, actions: { type: Array, default: () => [] },
},
client_case: Object,
actions: Array,
// optionally pre-select a contract to attach the activity to
contractUuid: { type: String, default: null }, contractUuid: { type: String, default: null },
phoneMode: { type: Boolean, default: false },
// Prefer passing these from parent (e.g., phone view) to avoid reliance on global page props in teleports
documents: { type: Array, default: null },
contracts: { type: Array, default: null },
}); });
const decisions = ref(props.actions[0].decisions); const page = usePage();
console.log(props.actions); const decisions = ref(
Array.isArray(props.actions) && props.actions.length > 0
? props.actions[0].decisions || []
: []
);
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
const close = () => emit("close");
const close = () => {
emit("close");
};
const form = useForm({ const form = useForm({
due_date: null, due_date: null,
amount: null, amount: null,
note: "", note: "",
action_id: props.actions[0].id, action_id:
decision_id: props.actions[0].decisions[0].id, Array.isArray(props.actions) && props.actions.length > 0 ? props.actions[0].id : null,
decision_id:
Array.isArray(props.actions) &&
props.actions.length > 0 &&
Array.isArray(props.actions[0].decisions) &&
props.actions[0].decisions.length > 0
? props.actions[0].decisions[0].id
: null,
contract_uuid: props.contractUuid, contract_uuid: props.contractUuid,
send_auto_mail: true, send_auto_mail: true,
attach_documents: false,
attachment_document_ids: [],
}); });
watch(
() => props.actions,
(list) => {
if (!Array.isArray(list) || list.length === 0) {
decisions.value = [];
form.action_id = null;
form.decision_id = null;
return;
}
if (!form.action_id) {
form.action_id = list[0].id;
}
const found = list.find((el) => el.id === form.action_id) || list[0];
decisions.value = Array.isArray(found.decisions) ? found.decisions : [];
if (!form.decision_id && decisions.value.length > 0) {
form.decision_id = decisions.value[0].id;
}
},
{ immediate: true }
);
watch( watch(
() => form.action_id, () => form.action_id,
(action_id) => { (action_id) => {
decisions.value = props.actions.filter((el) => el.id === action_id)[0].decisions; const a = Array.isArray(props.actions)
form.decision_id = decisions.value[0].id; ? props.actions.find((el) => el.id === action_id)
// reset send flag on action change (will re-evaluate below) : null;
decisions.value = Array.isArray(a?.decisions) ? a.decisions : [];
form.decision_id = decisions.value[0]?.id ?? null;
form.send_auto_mail = true; form.send_auto_mail = true;
} }
); );
watch(
() => form.due_date,
(due_date) => {
if (due_date) {
let date = new Date(form.due_date).toLocaleDateString("en-CA");
console.table({ old: due_date, new: date });
}
}
);
// keep contract_uuid synced if the prop changes while the drawer is open
watch( watch(
() => props.contractUuid, () => props.contractUuid,
(cu) => { (cu) => {
@ -69,48 +90,6 @@ watch(
} }
); );
const store = async () => {
console.table({
due_date: form.due_date,
action_id: form.action_id,
decision_id: form.decision_id,
amount: form.amount,
note: form.note,
});
// Helper to safely format a selected date (Date instance or parsable value) to YYYY-MM-DD
const formatDateForSubmit = (value) => {
if (!value) return null; // leave empty as null
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d.getTime())) return null; // invalid date -> null
// Avoid timezone shifting by constructing in local time
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`; // matches en-CA style YYYY-MM-DD
};
form
.transform((data) => ({
...data,
due_date: formatDateForSubmit(data.due_date),
}))
.post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => {
close();
// Preserve selected contract across submissions; reset only user-editable fields
form.reset("due_date", "amount", "note");
},
onError: (errors) => {
console.log("Validation or server error:", errors);
},
onFinish: () => {
console.log("Request finished processing.");
},
});
};
// When the drawer opens, always sync the current contractUuid into the form,
// even if the value hasn't changed (prevents stale/null contract_uuid after reset)
watch( watch(
() => props.show, () => props.show,
(visible) => { (visible) => {
@ -120,14 +99,53 @@ watch(
} }
); );
// Helper to read metadata for the currently selected decision const store = async () => {
const currentDecision = () => decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0]; const formatDateForSubmit = (value) => {
if (!value) return null;
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
form
.transform((data) => ({
...data,
due_date: formatDateForSubmit(data.due_date),
attachment_document_ids:
templateAllowsAttachments.value && data.attach_documents
? data.attachment_document_ids
: [],
}))
.post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => {
close();
form.reset("due_date", "amount", "note");
},
});
};
const currentDecision = () => {
if (!Array.isArray(decisions.value) || decisions.value.length === 0) {
return null;
}
return (
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
);
};
const showSendAutoMail = () => { const showSendAutoMail = () => {
const d = currentDecision(); const d = currentDecision();
return !!(d && d.auto_mail && d.email_template_id); return !!(d && d.auto_mail && d.email_template_id);
}; };
// Determine if the selected template requires a contract const templateAllowsAttachments = computed(() => {
const d = currentDecision();
const tmpl = d?.email_template || d?.emailTemplate || null;
return !!(tmpl && tmpl.allow_attachments);
});
const autoMailRequiresContract = computed(() => { const autoMailRequiresContract = computed(() => {
const d = currentDecision(); const d = currentDecision();
if (!d) return false; if (!d) return false;
@ -136,16 +154,14 @@ const autoMailRequiresContract = computed(() => {
return types.includes("contract"); return types.includes("contract");
}); });
// Disable checkbox when contract is required but none is selected
const autoMailDisabled = computed(() => { const autoMailDisabled = computed(() => {
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid; return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
}); });
const autoMailDisabledHint = computed(() => { const autoMailDisabledHint = computed(() => {
return autoMailDisabled.value ? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo." : ""; return autoMailDisabled.value
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
: "";
}); });
// If disabled, force the flag off to avoid accidental queue attempts
watch( watch(
() => autoMailDisabled.value, () => autoMailDisabled.value,
(disabled) => { (disabled) => {
@ -154,7 +170,106 @@ watch(
} }
} }
); );
const isToday = (val) => {
try {
if (!val) return false;
let d;
if (val instanceof Date) {
d = val;
} else if (typeof val === "string") {
// Normalize common MySQL timestamp 'YYYY-MM-DD HH:mm:ss' for Safari/iOS
const s = val.includes("T") ? val : val.replace(" ", "T");
d = new Date(s);
if (isNaN(d.getTime())) {
// Fallback: parse manually as local date
const m = val.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/);
if (m) {
const [_, yy, mm, dd, hh, mi, ss] = m;
d = new Date(
Number(yy),
Number(mm) - 1,
Number(dd),
Number(hh),
Number(mi),
Number(ss || "0")
);
}
}
} else if (typeof val === "number") {
d = new Date(val);
} else {
return false;
}
if (isNaN(d.getTime())) return false;
const today = new Date();
return (
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate()
);
} catch {
return false;
}
};
const docsSource = computed(() => {
if (Array.isArray(props.documents)) {
return props.documents;
}
const propsVal = page?.props?.value || {};
return Array.isArray(propsVal.documents) ? propsVal.documents : [];
});
const availableContractDocs = computed(() => {
if (!form.contract_uuid) return [];
const docs = docsSource.value;
const all = docs.filter((d) => d.contract_uuid === form.contract_uuid);
if (!props.phoneMode) return all;
return all.filter((d) => {
const mime = (d.mime_type || "").toLowerCase();
const isImage =
mime.startsWith("image/") ||
["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"].includes(
(d.extension || "").toLowerCase()
);
return isImage && isToday(d.created_at || d.createdAt);
});
});
const pageContracts = computed(() => {
if (Array.isArray(props.contracts)) {
return props.contracts;
}
const propsVal = page?.props?.value || {};
return Array.isArray(propsVal.contracts) ? propsVal.contracts : [];
});
watch(
[
() => props.phoneMode,
() => templateAllowsAttachments.value,
() => form.contract_uuid,
() => form.decision_id,
() => availableContractDocs.value.length,
],
() => {
if (!props.phoneMode) return;
if (!templateAllowsAttachments.value) return;
if (!form.contract_uuid) return;
const docs = availableContractDocs.value;
if (docs.length === 0) return;
form.attach_documents = true;
if (
!Array.isArray(form.attachment_document_ids) ||
form.attachment_document_ids.length === 0
) {
form.attachment_document_ids = docs.map((d) => d.id);
}
}
);
</script> </script>
<template> <template>
<DialogModal :show="show" @close="close"> <DialogModal :show="show" @close="close">
<template #title>Dodaj aktivnost</template> <template #title>Dodaj aktivnost</template>
@ -167,9 +282,9 @@ watch(
id="activityAction" id="activityAction"
ref="activityActionSelect" ref="activityActionSelect"
v-model="form.action_id" v-model="form.action_id"
:disabled="!actions || !actions.length"
> >
<option v-for="a in actions" :value="a.id">{{ a.name }}</option> <option v-for="a in actions" :key="a.id" :value="a.id">{{ a.name }}</option>
<!-- ... -->
</select> </select>
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
@ -179,9 +294,9 @@ watch(
id="activityDecision" id="activityDecision"
ref="activityDecisionSelect" ref="activityDecisionSelect"
v-model="form.decision_id" v-model="form.decision_id"
:disabled="!decisions || !decisions.length"
> >
<option v-for="d in decisions" :value="d.id">{{ d.name }}</option> <option v-for="d in decisions" :key="d.id" :value="d.id">{{ d.name }}</option>
<!-- ... -->
</select> </select>
</div> </div>
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
@ -225,13 +340,61 @@ watch(
v-model="form.send_auto_mail" v-model="form.send_auto_mail"
:disabled="autoMailDisabled" :disabled="autoMailDisabled"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
> />
<span>Send auto email</span> <span>Send auto email</span>
</label> </label>
</div> </div>
<p v-if="autoMailDisabled" class="mt-1 text-xs text-amber-600"> <p v-if="autoMailDisabled" class="mt-1 text-xs text-amber-600">
{{ autoMailDisabledHint }} {{ autoMailDisabledHint }}
</p> </p>
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="form.attach_documents" />
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
</label>
<div
v-if="form.attach_documents"
class="mt-2 border rounded p-2 max-h-48 overflow-auto"
>
<div class="text-xs text-gray-600 mb-2">
Izberite dokumente, ki bodo poslani kot priponke:
</div>
<div class="space-y-1">
<template v-for="c in pageContracts" :key="c.uuid || c.id">
<div v-if="c.uuid === form.contract_uuid">
<div class="font-medium text-sm text-gray-700 mb-1">
Pogodba {{ c.reference }}
</div>
<div class="space-y-1">
<div
v-for="doc in availableContractDocs"
:key="doc.uuid || doc.id"
class="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
:value="doc.id"
v-model="form.attachment_document_ids"
/>
<span>{{ doc.original_name || doc.name }}</span>
<span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }},
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
>
</div>
</div>
</div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div>
</div>
</div>
</div>
</div> </div>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<ActionMessage :on="form.recentlySuccessful" class="me-3"> <ActionMessage :on="form.recentlySuccessful" class="me-3">

View File

@ -9,6 +9,7 @@ import ContractTable from "./Partials/ContractTable.vue";
import ActivityDrawer from "./Partials/ActivityDrawer.vue"; import ActivityDrawer from "./Partials/ActivityDrawer.vue";
import ActivityTable from "./Partials/ActivityTable.vue"; import ActivityTable from "./Partials/ActivityTable.vue";
import DocumentsTable from "@/Components/DocumentsTable.vue"; import DocumentsTable from "@/Components/DocumentsTable.vue";
import DocumentEditDialog from "@/Components/DocumentEditDialog.vue";
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue"; import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue"; import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from "@/Services/documents"; import { classifyDocument } from "@/Services/documents";
@ -46,6 +47,21 @@ const onUploaded = () => {
router.reload({ only: ["documents"] }); router.reload({ only: ["documents"] });
}; };
// Document edit dialog state
const showDocEdit = ref(false)
const editingDoc = ref(null)
const openDocEdit = (doc) => {
editingDoc.value = doc
showDocEdit.value = true
}
const closeDocEdit = () => {
showDocEdit.value = false
editingDoc.value = null
}
const onDocSaved = () => {
router.reload({ only: ['documents'] })
}
const viewer = ref({ open: false, src: "", title: "" }); const viewer = ref({ open: false, src: "", title: "" });
const openViewer = (doc) => { const openViewer = (doc) => {
const kind = classifyDocument(doc); const kind = classifyDocument(doc);
@ -337,6 +353,7 @@ const submitAttachSegment = () => {
<DocumentsTable <DocumentsTable
:documents="documents" :documents="documents"
@view="openViewer" @view="openViewer"
@edit="openDocEdit"
:download-url-builder=" :download-url-builder="
(doc) => { (doc) => {
const isContractDoc = (doc?.documentable_type || '') const isContractDoc = (doc?.documentable_type || '')
@ -365,6 +382,14 @@ const submitAttachSegment = () => {
:post-url="route('clientCase.document.store', client_case)" :post-url="route('clientCase.document.store', client_case)"
:contracts="contracts" :contracts="contracts"
/> />
<DocumentEditDialog
:show="showDocEdit"
:client_case_uuid="client_case.uuid"
:document="editingDoc"
:contracts="contracts"
@close="closeDocEdit"
@saved="onDocSaved"
/>
<DocumentViewerDialog <DocumentViewerDialog
:show="viewer.open" :show="viewer.open"
:src="viewer.src" :src="viewer.src"
@ -386,6 +411,8 @@ const submitAttachSegment = () => {
:client_case="client_case" :client_case="client_case"
:actions="actions" :actions="actions"
:contract-uuid="activityContractUuid" :contract-uuid="activityContractUuid"
:documents="documents"
:contracts="contracts"
/> />
<ConfirmDialog <ConfirmDialog
:show="confirmDelete.show" :show="confirmDelete.show"

View File

@ -99,7 +99,13 @@ function activityActionLine(a) {
const drawerAddActivity = ref(false); const drawerAddActivity = ref(false);
const activityContractUuid = ref(null); const activityContractUuid = ref(null);
const openDrawerAddActivity = (c = null) => { const openDrawerAddActivity = (c = null) => {
activityContractUuid.value = c?.uuid ?? null; if (c && c.uuid) {
activityContractUuid.value = c.uuid;
} else if (Array.isArray(props.contracts) && props.contracts.length === 1) {
activityContractUuid.value = props.contracts[0]?.uuid ?? null;
} else {
activityContractUuid.value = null;
}
drawerAddActivity.value = true; drawerAddActivity.value = true;
}; };
const closeDrawer = () => { const closeDrawer = () => {
@ -530,6 +536,9 @@ const clientSummary = computed(() => {
:client_case="client_case" :client_case="client_case"
:actions="actions" :actions="actions"
:contract-uuid="activityContractUuid" :contract-uuid="activityContractUuid"
:phone-mode="true"
:documents="documents"
:contracts="contracts"
/> />
<ConfirmationModal :show="confirmComplete" @close="confirmComplete = false"> <ConfirmationModal :show="confirmComplete" @close="confirmComplete = false">

View File

@ -265,6 +265,9 @@
Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach'); Route::post('client-cases/{client_case:uuid}/segments', [ClientCaseContoller::class, 'attachSegment'])->name('clientCase.segments.attach');
// client-case / documents // client-case / documents
Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store'); Route::post('client-cases/{client_case:uuid}/documents', [ClientCaseContoller::class, 'storeDocument'])->name('clientCase.document.store');
Route::patch('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'updateDocument'])
->withoutScopedBindings()
->name('clientCase.document.update');
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view'); Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/view', [ClientCaseContoller::class, 'viewDocument'])->name('clientCase.document.view');
Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download'); Route::get('client-cases/{client_case:uuid}/documents/{document:uuid}/download', [ClientCaseContoller::class, 'downloadDocument'])->name('clientCase.document.download');
Route::delete('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteDocument'])->name('clientCase.document.delete'); Route::delete('client-cases/{client_case:uuid}/documents/{document:uuid}', [ClientCaseContoller::class, 'deleteDocument'])->name('clientCase.document.delete');

View File

@ -0,0 +1,103 @@
<?php
namespace Tests\Feature;
use App\Models\Action;
use App\Models\Activity;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Decision;
use App\Models\Document;
use App\Models\EmailLog;
use App\Models\EmailTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class EmailAttachmentsTest extends TestCase
{
use RefreshDatabase;
public function test_activity_with_attachments_queues_email_with_attachments(): void
{
Storage::fake('public');
// Auth with baseline permissions
$perm = Permission::firstOrCreate(['slug' => 'read'], ['name' => 'Read']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$user = User::factory()->create();
$user->roles()->syncWithoutDetaching([$role->id]);
$this->actingAs($user);
// Minimal domain graph: case -> contract
$case = ClientCase::factory()->create();
// Create a person email that is eligible to receive auto mails for the CLIENT's person
$case->load('client.person');
\App\Models\Email::factory()->create([
'person_id' => $case->client->person->id,
'receive_auto_mails' => true,
'is_active' => true,
]);
$action = Action::factory()->create();
$decision = Decision::factory()->create(['auto_mail' => true]);
$action->decisions()->attach($decision->id);
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
// Template allowing attachments and requiring contract entity context
$template = EmailTemplate::create([
'name' => 'Attach OK',
'key' => 'attach-ok',
'subject_template' => 'Subj',
'html_template' => '<p>Hello</p>',
'text_template' => 'Hello',
'entity_types' => ['contract'],
'allow_attachments' => true,
'active' => true,
]);
// Bind template to decision
\DB::table('decisions')->where('id', $decision->id)->update(['email_template_id' => $template->id]);
// Seed a document belonging to the contract
Storage::disk('public')->put('docs/a.txt', 'abc');
$doc = new Document([
'uuid' => (string) \Str::uuid(),
'name' => 'a.txt',
'original_name' => 'a.txt',
'disk' => 'public',
'path' => 'docs/a.txt',
'file_name' => 'a.txt',
'extension' => 'txt',
'mime_type' => 'text/plain',
'size' => 3,
'is_public' => true,
]);
$contract->documents()->save($doc);
// Exercise: create activity with attachment_document_ids
$response = $this->post(route('clientCase.activity.store', ['client_case' => $case->uuid]), [
'action_id' => $action->id,
'decision_id' => $decision->id,
'contract_uuid' => $contract->uuid,
'send_auto_mail' => true,
'attachment_document_ids' => [$doc->id],
]);
$response->assertSessionHasNoErrors();
// Validate activity created and email queued with attachments
$activity = Activity::latest()->first();
$this->assertNotNull($activity);
$this->assertEquals($action->id, $activity->action_id);
$this->assertEquals($decision->id, $activity->decision_id);
$log = EmailLog::latest()->first();
$this->assertNotNull($log);
$this->assertIsArray($log->attachments);
$this->assertCount(1, $log->attachments);
$this->assertEquals('docs/a.txt', $log->attachments[0]['path'] ?? null);
}
}

View File

@ -0,0 +1,75 @@
<?php
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\Document;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeCaseWithContractAndDoc(): array
{
$case = ClientCase::factory()->create();
$contract = Contract::factory()->for($case, 'clientCase')->create();
$doc = Document::factory()->create();
// Assign the document to the case initially
$doc->documentable_type = ClientCase::class;
$doc->documentable_id = $case->id;
$doc->save();
return [$case, $contract, $doc];
}
it('reassigns document to a contract within the same case', function () {
$user = User::factory()->create();
[$case, $contract, $doc] = makeCaseWithContractAndDoc();
$this->actingAs($user)
->patch(route('clientCase.document.update', [$case->uuid, $doc->uuid]), [
'contract_uuid' => $contract->uuid,
])
->assertRedirect();
$doc->refresh();
expect($doc->documentable_type)->toBe(Contract::class)
->and($doc->documentable_id)->toBe($contract->id);
});
it('returns validation error when assigning to a contract from another case', function () {
$user = User::factory()->create();
// Source case
[$caseA, $contractA, $doc] = makeCaseWithContractAndDoc();
// Different case and its contract
$caseB = ClientCase::factory()->create();
$contractB = Contract::factory()->for($caseB, 'clientCase')->create();
$this->actingAs($user)
->from(route('clientCase.show', $caseA->uuid))
->patch(route('clientCase.document.update', [$caseA->uuid, $doc->uuid]), [
'contract_uuid' => $contractB->uuid,
])
->assertSessionHasErrors('contract_uuid');
$doc->refresh();
// Still belongs to the original case
expect($doc->documentable_type)->toBe(ClientCase::class)
->and($doc->documentable_id)->toBe($caseA->id);
});
it('keeps or moves document to the case when contract_uuid is null/empty', function () {
$user = User::factory()->create();
[$case, $contract, $doc] = makeCaseWithContractAndDoc();
$this->actingAs($user)
->from(route('clientCase.show', $case->uuid))
->patch(route('clientCase.document.update', [$case->uuid, $doc->uuid]), [
// Simulate select "— Brez —" which typically posts an empty string
'contract_uuid' => '',
])
->assertRedirect();
$doc->refresh();
expect($doc->documentable_type)->toBe(ClientCase::class)
->and($doc->documentable_id)->toBe($case->id);
});