mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5b6a6aa6 | ||
|
|
bf2ebb995b | ||
|
|
3e929758fb | ||
|
|
4a53116586 | ||
|
|
2d1981acfa | ||
|
|
c610df269f | ||
|
|
96dac635f5 | ||
|
|
abd579103d | ||
|
|
7ed27bb413 | ||
|
|
180c542a9f | ||
|
|
89613113de | ||
|
|
3102900776 | ||
|
|
1a68053f4e | ||
|
|
5d4a032763 | ||
|
|
7481d27822 | ||
|
|
22afd01fd2 | ||
|
|
fd9eb09dd6 | ||
|
|
c53ed7f984 | ||
|
|
1d87e210be | ||
|
|
5fec952b8c | ||
|
|
c4fbcc103a | ||
|
|
9333a1ba45 | ||
|
|
de5cd92dba | ||
|
|
83e32bec9f | ||
|
|
4603efc04e | ||
|
|
6c73c82a27 | ||
|
|
8e71d9f9e9 | ||
|
|
80f60a5d88 | ||
|
|
2c33ec382d | ||
|
|
884865cffc | ||
|
|
9537fe8c91 | ||
|
|
c8fe36a2b6 | ||
|
|
7ec3282b56 | ||
|
|
ab8c41a520 | ||
|
|
4c1c70ff04 | ||
|
|
d7a6e80bb1 | ||
|
|
f87ef670d1 | ||
|
|
daba2505bc | ||
|
|
71528eda45 | ||
|
|
269e2beeaa | ||
|
|
72dd2c6bc5 | ||
|
|
9758f7a15f | ||
|
|
2521eb5a4a | ||
|
|
bf6afc5e61 | ||
|
|
291d869843 | ||
|
|
cdfecb2a8f | ||
|
|
c0df8e0ec3 | ||
|
|
a21f7d1653 | ||
|
|
79e2514416 | ||
|
|
80a7385183 | ||
|
|
1c8a7b6a7c | ||
|
|
b46ced2b6f | ||
|
|
5ae479644e | ||
|
|
cb66eb1293 | ||
|
|
b4d5a2cad4 | ||
|
|
32691b0c0d | ||
|
|
9163f744a3 | ||
|
|
23ecce51a3 | ||
|
|
747ffa8cf1 | ||
|
|
4e230899ed | ||
|
|
aff9f753c7 | ||
|
|
1127fac500 | ||
|
|
7707e1cd2b | ||
|
|
30274f7f1a | ||
|
|
a5da6cc781 | ||
|
|
201e4010c5 | ||
|
|
b9bbdbffae | ||
|
|
9e8e1996e8 | ||
|
|
b99235397a | ||
|
|
c0fd06daf3 | ||
|
|
90536294df | ||
|
|
f8e9787675 | ||
|
|
bf9b785946 | ||
|
|
dab3a6f031 | ||
|
|
88466193c5 | ||
|
|
c2f7601dad | ||
|
|
189f7b2d7b | ||
|
|
5d87160ee9 | ||
|
|
0cdcdb8898 | ||
|
|
c20619b5c8 |
No files matched your search
@@ -37,3 +37,4 @@ yarn-debug.log*
|
||||
/docuseal
|
||||
/ee
|
||||
dump.rdb
|
||||
*.onnx
|
||||
@@ -42,6 +42,15 @@ Metrics/CyclomaticComplexity:
|
||||
Metrics/PerceivedComplexity:
|
||||
Max: 15
|
||||
|
||||
Style/MultipleComparison:
|
||||
Enabled: false
|
||||
|
||||
Style/NumericPredicate:
|
||||
Enabled: false
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
Layout/LineLength:
|
||||
AllowedPatterns: ['\A\s*#']
|
||||
|
||||
|
||||
+6
-2
@@ -9,6 +9,7 @@ RUN apk --no-cache add fontforge wget && \
|
||||
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
|
||||
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.0.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
@@ -50,7 +51,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge yaml-dev redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
|
||||
\n\
|
||||
@@ -66,7 +67,9 @@ activate = 1' >> /app/openssl_legacy.cnf
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
|
||||
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -83,6 +86,7 @@ COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
|
||||
COPY --from=download /model.onnx /app/tmp/model.onnx
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -24,7 +24,9 @@ gem 'image_processing'
|
||||
gem 'jwt'
|
||||
gem 'lograge'
|
||||
gem 'mysql2', require: false
|
||||
gem 'numo-narray'
|
||||
gem 'oj'
|
||||
gem 'onnxruntime'
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
|
||||
+30
-15
@@ -76,7 +76,7 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
annotaterb (4.14.0)
|
||||
arabic-letter-connector (0.1.1)
|
||||
ast (2.4.2)
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1027.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
@@ -291,10 +291,10 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.13.2)
|
||||
json (2.15.0)
|
||||
jwt (2.9.3)
|
||||
base64
|
||||
language_server-protocol (3.17.0.3)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.0.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
@@ -305,6 +305,7 @@ GEM
|
||||
letter_opener (~> 1.9)
|
||||
railties (>= 6.1)
|
||||
rexml
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
@@ -356,17 +357,26 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oj (3.16.11)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1)
|
||||
ffi
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
ffi
|
||||
onnxruntime (0.10.1-arm64-darwin)
|
||||
ffi
|
||||
onnxruntime (0.10.1-x86_64-linux)
|
||||
ffi
|
||||
openssl (3.3.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
package_json (0.1.0)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.6.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
@@ -383,6 +393,7 @@ GEM
|
||||
pretender (0.5.0)
|
||||
actionpack (>= 6.1)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.5.1)
|
||||
pry (0.15.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
@@ -395,7 +406,7 @@ GEM
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (3.2.3)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
@@ -448,7 +459,7 @@ GEM
|
||||
psych (>= 4.0.0)
|
||||
redis-client (0.23.0)
|
||||
connection_pool
|
||||
regexp_parser (2.9.3)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
@@ -485,18 +496,20 @@ GEM
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.2)
|
||||
rubocop (1.69.2)
|
||||
rubocop (1.81.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-ast (1.47.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-performance (1.23.0)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
@@ -567,9 +580,9 @@ GEM
|
||||
tzinfo-data (1.2024.2)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.1.2)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
@@ -634,7 +647,9 @@ DEPENDENCIES
|
||||
letter_opener_web
|
||||
lograge
|
||||
mysql2
|
||||
numo-narray
|
||||
oj
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
|
||||
@@ -42,7 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- Mobile-optimized
|
||||
- 6 UI languages with signing available in 14 languages
|
||||
- 7 UI languages with signing available in 14 languages
|
||||
- API and Webhooks for integrations
|
||||
- Easy to deploy in minutes
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ class AccountsController < ApplicationController
|
||||
'es-ES' => 'Español',
|
||||
'pt-PT' => 'Português',
|
||||
'de-DE' => 'Deutsch',
|
||||
'it-IT' => 'Italiano'
|
||||
'it-IT' => 'Italiano',
|
||||
'nl-NL' => 'Nederlands'
|
||||
}.freeze
|
||||
|
||||
before_action :load_account
|
||||
@@ -46,6 +47,7 @@ class AccountsController < ApplicationController
|
||||
authorize!(:manage, current_account)
|
||||
|
||||
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
|
||||
true_user.account.update!(archived_at: Time.current)
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
|
||||
@@ -34,6 +34,10 @@ module Api
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
|
||||
@@ -147,7 +147,7 @@ module Api
|
||||
is_send_email = !params[:send_email].in?(['false', false])
|
||||
|
||||
if (emails = (params[:emails] || params[:email]).presence) &&
|
||||
(params[:submission].blank? && params[:submitters].blank?)
|
||||
params[:submission].blank? && params[:submitters].blank?
|
||||
Submissions.create_from_emails(template:,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
|
||||
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
|
||||
scope: :console,
|
||||
exp: 1.minute.from_now.to_i)
|
||||
|
||||
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
|
||||
redir_uri = Addressable::URI.parse(params[:redir])
|
||||
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
|
||||
|
||||
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
|
||||
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
|
||||
allow_other_host: true
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ class PersonalizationSettingsController < ApplicationController
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
|
||||
AccountConfig::FORM_COMPLETED_MESSAGE_KEY,
|
||||
|
||||
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
|
||||
respond_to do |f|
|
||||
f.html { render :success }
|
||||
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def already_sent?(submitter)
|
||||
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
|
||||
def can_send?(submitter)
|
||||
return false if submitter.account.archived_at?
|
||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -41,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
def completed
|
||||
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
|
||||
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
|
||||
|
||||
@template = @submission.template
|
||||
|
||||
render :completed, layout: 'form'
|
||||
|
||||
@@ -75,7 +75,9 @@ class SubmitFormController < ApplicationController
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def completed; end
|
||||
def completed
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
|
||||
end
|
||||
|
||||
def success; end
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
class TemplateDocumentsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
if params[:blobs].blank? && params[:files].blank?
|
||||
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
|
||||
|
||||
@@ -118,7 +118,8 @@ class TemplatesController < ApplicationController
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
{ schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]],
|
||||
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
|
||||
{ conditions: [%i[field_uuid value action operation]] }]],
|
||||
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
|
||||
@@ -6,12 +6,18 @@ class TemplatesDebugController < ApplicationController
|
||||
DEBUG_FILE = ''
|
||||
|
||||
def show
|
||||
attachment = @template.documents.first
|
||||
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
|
||||
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
|
||||
|
||||
data = attachment.download
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
unless attachment.image?
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
end
|
||||
|
||||
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['fields'] = fields
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesDetectFieldsController < ApplicationController
|
||||
include ActionController::Live
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
response.headers['Content-Type'] = 'text/event-stream'
|
||||
|
||||
sse = SSE.new(response.stream)
|
||||
|
||||
documents = @template.schema_documents.preload(:blob)
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
|
||||
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
|
||||
sse.write({ attachment_uuid:, page:, fields: })
|
||||
end
|
||||
end
|
||||
|
||||
sse.write({ completed: true })
|
||||
ensure
|
||||
response.stream.close
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,15 @@
|
||||
class TemplatesPreferencesController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
RESETTABLE_PREFERENCE_KEYS = {
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||
invitation_reminder_email_body],
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY => %w[completed_notification_email_subject
|
||||
completed_notification_email_body]
|
||||
}.freeze
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@@ -15,19 +24,38 @@ class TemplatesPreferencesController < ApplicationController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize!(:update, @template)
|
||||
|
||||
config_key = params[:config_key]
|
||||
preferences_to_delete = RESETTABLE_PREFERENCE_KEYS[config_key]
|
||||
|
||||
return head :ok if preferences_to_delete.blank?
|
||||
|
||||
preferences_to_delete.each do |key|
|
||||
@template.preferences.delete(key)
|
||||
end
|
||||
|
||||
@template.save!
|
||||
|
||||
render turbo_stream: turbo_stream.replace("#{config_key}_form",
|
||||
partial: "templates_preferences/#{config_key}_form"),
|
||||
status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
||||
invitation_reminder_email_subject invitation_reminder_email_body
|
||||
documents_copy_email_subject documents_copy_email_body
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url validate_unique_submitters
|
||||
require_all_submitters submitters_order require_phone_2fa
|
||||
default_expire_at_duration shared_link_2fa
|
||||
default_expire_at request_email_enabled
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
|
||||
@@ -24,10 +24,19 @@ class UsersController < ApplicationController
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
if User.accessible_by(current_ability).exists?(email: @user.email)
|
||||
@user.errors.add(:email, I18n.t('already_exists'))
|
||||
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
|
||||
|
||||
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
|
||||
if existing_user
|
||||
if existing_user.archived_at? &&
|
||||
current_ability.can?(:manage, existing_user) && current_ability.can?(:manage, @user.account)
|
||||
existing_user.assign_attributes(@user.slice(:first_name, :last_name, :role, :account_id))
|
||||
existing_user.archived_at = nil
|
||||
@user = existing_user
|
||||
else
|
||||
@user.errors.add(:email, I18n.t('already_exists'))
|
||||
|
||||
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
@user.password = SecureRandom.hex if @user.password.blank?
|
||||
|
||||
@@ -49,6 +49,9 @@ import ShowOnValue from './elements/show_on_value'
|
||||
import CustomValidation from './elements/custom_validation'
|
||||
import ToggleClasses from './elements/toggle_classes'
|
||||
import AutosizeField from './elements/autosize_field'
|
||||
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
|
||||
import OpenModal from './elements/open_modal'
|
||||
import BarChart from './elements/bar_chart'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -136,6 +139,9 @@ safeRegisterElement('show-on-value', ShowOnValue)
|
||||
safeRegisterElement('custom-validation', CustomValidation)
|
||||
safeRegisterElement('toggle-classes', ToggleClasses)
|
||||
safeRegisterElement('autosize-field', AutosizeField)
|
||||
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
|
||||
safeRegisterElement('open-modal', OpenModal)
|
||||
safeRegisterElement('bar-chart', BarChart)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
@@ -152,6 +158,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
@@ -160,7 +167,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withSendButton: this.dataset.withSendButton !== 'false',
|
||||
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
|
||||
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
|
||||
|
||||
this.initChart()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy()
|
||||
this.chartInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
async initChart () {
|
||||
const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto')
|
||||
|
||||
const canvas = this.querySelector('canvas')
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
this.chartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: this.chartLabels,
|
||||
datasets: this.chartDatasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grace: '20%',
|
||||
ticks: {
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const iframeTemplate = this.querySelector('template')
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some(e => e.isIntersecting)) {
|
||||
iframeTemplate.parentElement.prepend(iframeTemplate.content)
|
||||
|
||||
this.observer.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
this.observer.observe(this)
|
||||
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
}
|
||||
|
||||
messageHandler = (event) => {
|
||||
if (event.data.type === 'google-drive-files-picked') {
|
||||
this.form.querySelectorAll('input[name="google_drive_file_ids[]"]').forEach(el => el.remove())
|
||||
|
||||
const files = event.data.files || []
|
||||
|
||||
files.forEach((file) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'hidden'
|
||||
input.name = 'google_drive_file_ids[]'
|
||||
input.value = file.id
|
||||
this.form.appendChild(input)
|
||||
})
|
||||
|
||||
this.form.querySelector('button[type="submit"]').click()
|
||||
this.loader.classList.remove('hidden')
|
||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||
this.loader.classList.add('hidden')
|
||||
this.form.classList.remove('hidden')
|
||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||
document.getElementById(this.dataset.oauthButtonId).classList.remove('hidden')
|
||||
this.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.observer?.unobserve(this)
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
|
||||
get loader () {
|
||||
return document.getElementById('google_drive_loader')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const src = this.getAttribute('src')
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = src
|
||||
link.setAttribute('data-turbo-frame', 'modal')
|
||||
link.style.display = 'none'
|
||||
|
||||
this.appendChild(link)
|
||||
|
||||
link.click()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,10 @@ export default {
|
||||
style.color = this.field.preferences.color
|
||||
}
|
||||
|
||||
if (this.field.preferences?.background) {
|
||||
style.background = this.field.preferences.background
|
||||
}
|
||||
|
||||
return style
|
||||
},
|
||||
isNarrow () {
|
||||
|
||||
@@ -163,12 +163,20 @@ export default {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
return data
|
||||
}).then(async (resp) => {
|
||||
const data = await resp.json()
|
||||
|
||||
if (resp.status === 422) {
|
||||
alert(data.error)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
})
|
||||
}
|
||||
})).then((result) => {
|
||||
this.$emit('upload', result)
|
||||
if (result && result[0]) {
|
||||
this.$emit('upload', result)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
|
||||
@@ -91,7 +91,7 @@ const en = {
|
||||
type_text: 'Type text',
|
||||
email_has_been_sent: 'Email has been sent',
|
||||
processing: 'Processing',
|
||||
pay_with_strip: 'Pay with Stripe',
|
||||
pay_with_stripe: 'Pay with Stripe',
|
||||
reupload: 'Reupload',
|
||||
upload: 'Upload',
|
||||
files: 'Files',
|
||||
@@ -192,7 +192,7 @@ const es = {
|
||||
type_text: 'Escribir texto',
|
||||
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
||||
processing: 'Procesando',
|
||||
pay_with_strip: 'Pagar con Stripe',
|
||||
pay_with_stripe: 'Pagar con Stripe',
|
||||
reupload: 'Volver a subir',
|
||||
upload: 'Subir',
|
||||
files: 'Archivos',
|
||||
@@ -222,7 +222,7 @@ const it = {
|
||||
value_is_invalid: 'Il valore non è valido',
|
||||
verification_code_is_invalid: 'Il codice di verifica non è valido',
|
||||
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scansiona il codice QR con l\'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
|
||||
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
|
||||
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
|
||||
esignature_disclosure: 'Divulgazione della eFirma',
|
||||
@@ -293,7 +293,7 @@ const it = {
|
||||
toggle_multiline_text: 'Attiva Testo Multilinea',
|
||||
email_has_been_sent: "L'email è stata inviata",
|
||||
processing: 'Elaborazione',
|
||||
pay_with_strip: 'Paga con Stripe',
|
||||
pay_with_stripe: 'Paga con Stripe',
|
||||
reupload: 'Ricarica',
|
||||
upload: 'Carica',
|
||||
files: 'File',
|
||||
@@ -304,64 +304,64 @@ const it = {
|
||||
const de = {
|
||||
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
|
||||
must_be_characters_length: 'Muss {number} Zeichen lang sein',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
||||
verify_id: 'ID überprüfen',
|
||||
identity_verification: 'Identitätsüberprüfung',
|
||||
identity_verification: 'Identitätsprüfung',
|
||||
complete: 'Abschließen',
|
||||
fill_all_required_fields_to_complete: 'Alle erforderlichen Felder ausfüllen, um abzuschließen',
|
||||
sign_and_complete: 'Signieren und Abschließen',
|
||||
fill_all_required_fields_to_complete: 'Füllen Sie alle Pflichtfelder aus, um abzuschließen',
|
||||
sign_and_complete: 'Unterzeichnen und abschließen',
|
||||
text: 'Text',
|
||||
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie dem',
|
||||
electronic_signature_disclosure: 'Hinweis zur E-Signatur',
|
||||
esignature_disclosure: 'Hinweis zur eSignatur',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
drawn_signature_on_a_touchscreen_device: 'Auf einem Touchscreen-Gerät gezeichnete Unterschrift',
|
||||
approved: 'Genehmigt',
|
||||
reviewed: 'Geprüft',
|
||||
other: 'Sonstiges',
|
||||
authored_by_me: 'Von mir erstellt',
|
||||
invite: 'Einladen',
|
||||
email: 'E-Mail',
|
||||
approved: 'Genehmigt',
|
||||
reviewed: 'Überprüft',
|
||||
other: 'Andere',
|
||||
authored_by_me: 'Von mir verfasst',
|
||||
approved_by: 'Genehmigt von',
|
||||
reviewed_by: 'Überprüft von',
|
||||
authored_by: 'Verfasst von',
|
||||
reviewed_by: 'Geprüft von',
|
||||
authored_by: 'Erstellt von',
|
||||
select_a_reason: 'Grund auswählen',
|
||||
value_is_invalid: 'Wert ist ungültig',
|
||||
verification_code_is_invalid: 'Bestätigungscode ist ungültig',
|
||||
drawn_signature_on_a_touchscreen_device: 'Gezeichnete Unterschrift auf einem Touchscreen-Gerät',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Handy zu öffnen und Ihre Unterschrift zu zeichnen',
|
||||
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie zu, dass Sie die',
|
||||
electronic_signature_disclosure: 'Elektronische Unterschriftenoffenlegung',
|
||||
esignature_disclosure: 'eSignatur Offenlegung',
|
||||
already_paid: 'Bereits bezahlt',
|
||||
minimize: 'Minimieren',
|
||||
text: 'Text',
|
||||
signature: 'Unterschrift',
|
||||
digitally_signed_by: 'Digital unterschrieben von',
|
||||
reason: 'Grund',
|
||||
initials: 'Initialen',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Mobilgerät zu öffnen und Ihre Unterschrift zu zeichnen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
number: 'Zahl',
|
||||
value_is_invalid: 'Wert ist ungültig',
|
||||
verification_code_is_invalid: 'Verifizierungscode ist ungültig',
|
||||
already_paid: 'Bereits bezahlt',
|
||||
image: 'Bild',
|
||||
pay: 'Bezahlen',
|
||||
take_photo: 'Foto aufnehmen',
|
||||
number_phone_is_invalid: 'Telefonnummer ({number}) ist ungültig',
|
||||
file: 'Datei',
|
||||
select: 'Auswählen',
|
||||
digitally_signed_by: 'Elektronisch signiert von',
|
||||
reason: 'Grund',
|
||||
select: 'Auswahl',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Mehrere',
|
||||
multiple: 'Mehrfach',
|
||||
radio: 'Radio',
|
||||
cells: 'Zellen',
|
||||
stamp: 'Stempel',
|
||||
minimize: 'Minimieren',
|
||||
payment: 'Zahlung',
|
||||
number_phone_is_invalid: '{number} Telefonnummer ist ungültig',
|
||||
phone: 'Telefon',
|
||||
start_now: 'Jetzt starten',
|
||||
continue: 'Fortsetzen',
|
||||
sign_now: 'Jetzt unterschreiben',
|
||||
continue: 'Weiter',
|
||||
sign_now: 'Jetzt unterzeichnen',
|
||||
type_here_: 'Hier eingeben...',
|
||||
optional: 'optional',
|
||||
option: 'Option',
|
||||
appears_on: 'Erscheint auf',
|
||||
page: 'Seite',
|
||||
take_photo: 'Foto aufnehmen',
|
||||
select_your_option: 'Wähle deine Option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
|
||||
submit: 'absenden',
|
||||
next: 'weiter',
|
||||
click_to_upload: 'Klicken zum Hochladen',
|
||||
select_your_option: 'Option auswählen',
|
||||
complete_hightlighted_checkboxes_and_click: 'Füllen Sie die markierten Kontrollkästchen aus und klicken Sie auf',
|
||||
submit: 'Einreichen',
|
||||
next: 'Weiter',
|
||||
click_to_upload: 'Zum Hochladen klicken',
|
||||
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
||||
send_copy_via_email: 'Kopie per E-Mail senden',
|
||||
download: 'Herunterladen',
|
||||
@@ -370,137 +370,137 @@ const de = {
|
||||
draw_initials: 'Initialen zeichnen',
|
||||
type_signature_here: 'Unterschrift hier eingeben',
|
||||
type_initial_here: 'Initialen hier eingeben',
|
||||
form_has_been_completed: 'Formular wurde ausgefüllt!',
|
||||
document_has_been_signed: 'Dokument wurde unterschrieben!',
|
||||
documents_have_been_signed: 'Dokumente wurden unterschrieben!',
|
||||
form_has_been_completed: 'Formular wurde abgeschlossen!',
|
||||
document_has_been_signed: 'Dokument wurde unterzeichnet!',
|
||||
documents_have_been_signed: 'Dokumente wurden unterzeichnet!',
|
||||
create_a_free_account: 'Kostenloses Konto erstellen',
|
||||
powered_by: 'Bereitgestellt von',
|
||||
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren.',
|
||||
please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
|
||||
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
||||
verified_phone_number: 'Telefonnummer überprüfen',
|
||||
verified_phone_number: 'Telefonnummer verifizieren',
|
||||
use_international_format: 'Internationales Format verwenden: +1xxx',
|
||||
six_digits_code: '6-stelliger Code',
|
||||
change_phone_number: 'Telefonnummer ändern',
|
||||
sending: 'Senden...',
|
||||
resend_code: 'Code erneut senden',
|
||||
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
|
||||
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
|
||||
set_today: 'Heute einstellen',
|
||||
verification_code_has_been_resent: 'Der Verifizierungscode wurde per SMS erneut gesendet',
|
||||
please_fill_all_required_fields: 'Bitte füllen Sie alle Pflichtfelder aus',
|
||||
set_today: 'Heute',
|
||||
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
|
||||
draw_signature: 'Unterschrift zeichnen',
|
||||
type_initial: 'Initialen eingeben',
|
||||
draw: 'Zeichnen',
|
||||
type: 'Eingeben',
|
||||
type_text: 'Text eingeben',
|
||||
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
|
||||
email_has_been_sent: 'Die E-Mail wurde gesendet',
|
||||
email_has_been_sent: 'E-Mail wurde gesendet',
|
||||
processing: 'Verarbeitung',
|
||||
pay_with_strip: 'Mit Stripe bezahlen',
|
||||
pay_with_stripe: 'Mit Stripe bezahlen',
|
||||
reupload: 'Erneut hochladen',
|
||||
upload: 'Hochladen',
|
||||
files: 'Dateien',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
|
||||
wait_countdown_seconds: 'Warte {countdown} Sekunden'
|
||||
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
|
||||
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
please_upload_an_image_file: 'Veuillez télécharger un fichier image',
|
||||
must_be_characters_length: 'Doit contenir {number} caractères',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
|
||||
verify_id: "Vérification de l'ID",
|
||||
identity_verification: "Vérification de l'identité",
|
||||
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
|
||||
must_be_characters_length: 'Doit comporter {number} caractères',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
||||
verify_id: "Vérifier l'ID",
|
||||
identity_verification: "Vérification d'identité",
|
||||
complete: 'Terminer',
|
||||
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter',
|
||||
sign_and_complete: 'Signer et Terminer',
|
||||
invite: 'Inviter',
|
||||
email: 'Courriel',
|
||||
fill_all_required_fields_to_complete: 'Remplissez tous les champs obligatoires pour terminer',
|
||||
sign_and_complete: 'Signer et terminer',
|
||||
text: 'Texte',
|
||||
by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
|
||||
electronic_signature_disclosure: 'Déclaration relative à la signature électronique',
|
||||
esignature_disclosure: 'Déclaration eSignature',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
|
||||
approved: 'Approuvé',
|
||||
reviewed: 'Révisé',
|
||||
other: 'Autre',
|
||||
authored_by_me: 'Rédigé par moi',
|
||||
invite: 'Inviter',
|
||||
email: 'E-mail',
|
||||
approved_by: 'Approuvé par',
|
||||
reviewed_by: 'Révisé par',
|
||||
authored_by: 'Rédigé par',
|
||||
select_a_reason: 'Sélectionnez une raison',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le QR avec l’appareil photo pour ouvrir le formulaire et signer sur mobile',
|
||||
date: 'Date',
|
||||
number: 'Nombre',
|
||||
value_is_invalid: 'La valeur est invalide',
|
||||
verification_code_is_invalid: 'Le code de vérification est invalide',
|
||||
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le code QR avec l\'application de l\'appareil photo pour ouvrir le formulaire sur mobile et dessiner votre signature',
|
||||
by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
|
||||
electronic_signature_disclosure: 'Divulgation de Signature Électronique',
|
||||
esignature_disclosure: 'Divulgation de la eSignature',
|
||||
minimize: 'Réduire',
|
||||
text: 'Texte',
|
||||
already_paid: 'Déjà payé',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
digitally_signed_by: 'Signé numériquement par',
|
||||
reason: 'Raison',
|
||||
pay: 'Payer',
|
||||
date: 'Date',
|
||||
number: 'Numéro',
|
||||
image: 'Image',
|
||||
pay: 'Payer',
|
||||
take_photo: 'Prendre une photo',
|
||||
number_phone_is_invalid: "Le numéro de téléphone {number} n'est pas valide",
|
||||
file: 'Fichier',
|
||||
select: 'Choisir',
|
||||
digitally_signed_by: 'Signé électroniquement par',
|
||||
reason: 'Motif',
|
||||
select: 'Sélectionner',
|
||||
checkbox: 'Coche',
|
||||
multiple: 'Multiple',
|
||||
radio: 'Radio',
|
||||
cells: 'Cellules',
|
||||
stamp: 'Tampon',
|
||||
minimize: 'Réduire',
|
||||
payment: 'Paiement',
|
||||
number_phone_is_invalid: '{number} téléphone est invalide',
|
||||
phone: 'Téléphone',
|
||||
start_now: 'Commencer maintenant',
|
||||
continue: 'Continuer',
|
||||
sign_now: 'Signer maintenant',
|
||||
type_here_: 'Tapez ici...',
|
||||
type_here_: 'Saisissez ici...',
|
||||
optional: 'facultatif',
|
||||
option: 'Option',
|
||||
appears_on: 'Apparaît sur',
|
||||
page: 'Page',
|
||||
take_photo: 'Prendre une photo',
|
||||
select_your_option: 'Sélectionnez votre option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
|
||||
submit: 'envoyer',
|
||||
complete_hightlighted_checkboxes_and_click: 'Cochez les cases mises en évidence puis cliquez',
|
||||
submit: 'soumettre',
|
||||
next: 'suivant',
|
||||
click_to_upload: 'Cliquez pour télécharger',
|
||||
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
|
||||
click_to_upload: 'Cliquez pour téléverser',
|
||||
or_drag_and_drop_files: 'ou faites glisser et déposez des fichiers',
|
||||
send_copy_via_email: 'Envoyer une copie par e-mail',
|
||||
download: 'Télécharger',
|
||||
clear: 'Effacer',
|
||||
redraw: 'Redessiner',
|
||||
draw_initials: 'Dessiner les initiales',
|
||||
type_signature_here: 'Tapez la signature ici',
|
||||
type_initial_here: 'Tapez les initiales ici',
|
||||
form_has_been_completed: 'Le formulaire a été complété !',
|
||||
document_has_been_signed: 'Le document a été signé!',
|
||||
documents_have_been_signed: 'Les documents ont été signés!',
|
||||
create_a_free_account: 'Créer un Compte Gratuit',
|
||||
type_signature_here: 'Saisissez la signature ici',
|
||||
type_initial_here: 'Saisissez les initiales ici',
|
||||
form_has_been_completed: 'Le formulaire a été rempli !',
|
||||
document_has_been_signed: 'Le document a été signé !',
|
||||
documents_have_been_signed: 'Les documents ont été signés !',
|
||||
create_a_free_account: 'Créer un compte gratuit',
|
||||
powered_by: 'Propulsé par',
|
||||
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
|
||||
open_source_documents_software: 'logiciel de documents open source',
|
||||
verified_phone_number: 'Vérifier le numéro de téléphone',
|
||||
use_international_format: 'Utiliser le format international : +1xxx',
|
||||
use_international_format: 'Utilisez le format international : +1xxx',
|
||||
six_digits_code: 'Code à 6 chiffres',
|
||||
change_phone_number: 'Changer le numéro de téléphone',
|
||||
sending: 'Envoi en cours...',
|
||||
change_phone_number: 'Changer de numéro de téléphone',
|
||||
sending: 'Envoi...',
|
||||
resend_code: 'Renvoyer le code',
|
||||
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
|
||||
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
|
||||
set_today: "Définir Aujourd'hui",
|
||||
draw_signature: 'Dessiner une signature',
|
||||
set_today: "Définir à aujourd'hui",
|
||||
toggle_multiline_text: 'Basculer le texte multiligne',
|
||||
draw_signature: 'Dessiner la signature',
|
||||
type_initial: 'Saisir les initiales',
|
||||
draw: 'Dessiner',
|
||||
type: 'Saisir',
|
||||
type_text: 'Saisir du texte',
|
||||
toggle_multiline_text: 'Basculer le Texte Multiligne',
|
||||
email_has_been_sent: "L'email a été envoyé",
|
||||
processing: 'Traitement',
|
||||
pay_with_strip: 'Paiement avec Stripe',
|
||||
reupload: 'Recharger',
|
||||
upload: 'Télécharger',
|
||||
email_has_been_sent: "L'e-mail a été envoyé",
|
||||
processing: 'Traitement en cours',
|
||||
pay_with_stripe: 'Payer avec Stripe',
|
||||
reupload: 'Retéléverser',
|
||||
upload: 'Téléverser',
|
||||
files: 'Fichiers',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
|
||||
wait_countdown_seconds: 'Attendez {countdown} secondes'
|
||||
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
|
||||
}
|
||||
|
||||
const pl = {
|
||||
@@ -596,7 +596,7 @@ const pl = {
|
||||
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
|
||||
email_has_been_sent: 'E-mail został wysłany',
|
||||
processing: 'Przetwarzanie',
|
||||
pay_with_strip: 'Płatność za pomocą Stripe',
|
||||
pay_with_stripe: 'Płatność za pomocą Stripe',
|
||||
reupload: 'Ponowne przesłanie',
|
||||
upload: 'Przesyłanie',
|
||||
files: 'Pliki',
|
||||
@@ -798,7 +798,7 @@ const cs = {
|
||||
toggle_multiline_text: 'Přepnout Víceřádkový Text',
|
||||
email_has_been_sent: 'E-mail byl odeslán',
|
||||
processing: 'Zpracování',
|
||||
pay_with_strip: 'Zaplacení přes Stripe',
|
||||
pay_with_stripe: 'Zaplacení přes Stripe',
|
||||
reupload: 'Znovu nahrát',
|
||||
upload: 'Nahrát',
|
||||
files: 'Soubory',
|
||||
@@ -899,7 +899,7 @@ const pt = {
|
||||
toggle_multiline_text: 'Alternar Texto Multilinha',
|
||||
email_has_been_sent: 'Email enviado',
|
||||
processing: 'Processamento',
|
||||
pay_with_strip: 'Pagar com Stripe',
|
||||
pay_with_stripe: 'Pagar com Stripe',
|
||||
reupload: 'Reenviar',
|
||||
upload: 'Carregar',
|
||||
files: 'Arquivos',
|
||||
@@ -1000,7 +1000,7 @@ const he = {
|
||||
type_text: 'הקלד טקסט',
|
||||
email_has_been_sent: 'האימייל נשלח',
|
||||
processing: 'מעבד',
|
||||
pay_with_strip: 'שלם עם סטרייפ',
|
||||
pay_with_stripe: 'שלם עם סטרייפ',
|
||||
reupload: 'העלה שוב',
|
||||
upload: 'העלאה',
|
||||
files: 'קבצים',
|
||||
@@ -1101,7 +1101,7 @@ const nl = {
|
||||
type_text: 'Typ tekst',
|
||||
email_has_been_sent: 'E-mail is verzonden',
|
||||
processing: 'Verwerken',
|
||||
pay_with_strip: 'Betalen met Stripe',
|
||||
pay_with_stripe: 'Betalen met Stripe',
|
||||
reupload: 'Opnieuw uploaden',
|
||||
upload: 'Uploaden',
|
||||
files: 'Bestanden',
|
||||
@@ -1303,7 +1303,7 @@ const ko = {
|
||||
type_text: '텍스트 입력',
|
||||
email_has_been_sent: '이메일이 전송되었습니다',
|
||||
processing: '처리 중',
|
||||
pay_with_strip: '스트라이프로 결제',
|
||||
pay_with_stripe: '스트라이프로 결제',
|
||||
reupload: '다시 업로드',
|
||||
upload: '업로드',
|
||||
files: '파일',
|
||||
@@ -1404,7 +1404,7 @@ const ja = {
|
||||
type_text: 'テキストを入力',
|
||||
email_has_been_sent: 'メールが送信されました',
|
||||
processing: '処理中',
|
||||
pay_with_strip: 'Stripeで支払う',
|
||||
pay_with_stripe: 'Stripeで支払う',
|
||||
reupload: '再アップロード',
|
||||
upload: 'アップロード',
|
||||
files: 'ファイル',
|
||||
|
||||
@@ -123,6 +123,11 @@
|
||||
v-if="!isDrawInitials"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<label
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
for="initials_text_input"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
width="22"
|
||||
/>
|
||||
<span>
|
||||
{{ t('pay_with_strip') }}
|
||||
{{ t('pay_with_stripe') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
@input="updateWrittenSignature"
|
||||
>
|
||||
<select
|
||||
v-if="requireSigningReason && !isOtherReason"
|
||||
v-if="withSigningReason && !isOtherReason"
|
||||
class="select base-input !text-2xl w-full mt-6 text-center"
|
||||
:class="{ 'text-gray-300': !reason }"
|
||||
required
|
||||
@@ -226,24 +226,37 @@
|
||||
>
|
||||
{{ t('select_a_reason') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(label, option) in defaultReasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
<option
|
||||
value="other"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ t('other') }}
|
||||
</option>
|
||||
<template v-if="field.preferences?.reasons">
|
||||
<option
|
||||
v-for="option in field.preferences.reasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<option
|
||||
v-for="(label, option) in defaultReasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
<option
|
||||
value="other"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ t('other') }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<input
|
||||
v-if="requireSigningReason && isOtherReason"
|
||||
v-if="withSigningReason && isOtherReason"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
required
|
||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||
@@ -253,7 +266,7 @@
|
||||
@input="$emit('update:reason', $event.target.value)"
|
||||
>
|
||||
<input
|
||||
v-if="requireSigningReason"
|
||||
v-if="withSigningReason"
|
||||
hidden
|
||||
name="with_reason"
|
||||
:value="field.preferences.reason_field_uuid"
|
||||
@@ -406,6 +419,9 @@ export default {
|
||||
format () {
|
||||
return this.field.preferences?.format
|
||||
},
|
||||
withSigningReason () {
|
||||
return this.requireSigningReason || this.field.preferences?.reasons?.length
|
||||
},
|
||||
defaultReasons () {
|
||||
return {
|
||||
[this.t('approved_by')]: this.t('approved'),
|
||||
@@ -424,10 +440,11 @@ export default {
|
||||
created () {
|
||||
this.isSignatureStarted = !!this.computedPreviousValue
|
||||
|
||||
if (this.requireSigningReason) {
|
||||
if (this.withSigningReason) {
|
||||
this.field.preferences ||= {}
|
||||
this.field.preferences.reason_field_uuid ||= v4()
|
||||
this.isOtherReason = this.reason && !this.defaultReasons[this.reason]
|
||||
this.isOtherReason = this.reason && !this.defaultReasons[this.reason] &&
|
||||
(!this.field.preferences?.reasons?.length || !this.field.preferences.reasons.includes(this.reason))
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
|
||||
@@ -175,7 +175,10 @@
|
||||
{{ t('save') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div
|
||||
class="dropdown dropdown-end"
|
||||
:class="{ 'dropdown-open': isDownloading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"
|
||||
@@ -209,6 +212,30 @@
|
||||
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="withDownload">
|
||||
<button
|
||||
class="flex space-x-2"
|
||||
:disabled="isDownloading"
|
||||
@click.stop.prevent="download"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isDownloading"
|
||||
class="animate-spin w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<IconDownload
|
||||
v-else
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-if="isDownloading"
|
||||
class="whitespace-nowrap"
|
||||
>{{ t('downloading_') }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-nowrap"
|
||||
>{{ t('download') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
@@ -260,8 +287,12 @@
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<Upload
|
||||
v-if="sortedDocuments.length && editable && withUploadButton"
|
||||
v-if="editable && withUploadButton"
|
||||
v-show="sortedDocuments.length"
|
||||
ref="upload"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:authenticity-token="authenticityToken"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
:template-id="template.id"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
@@ -297,6 +328,8 @@
|
||||
v-if="withUploadButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
@click-google-drive="$refs.upload.openGoogleDriveModal()"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
<button
|
||||
@@ -368,6 +401,8 @@
|
||||
v-if="withUploadButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:authenticity-token="authenticityToken"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
@success="updateFromUpload"
|
||||
/>
|
||||
<button
|
||||
@@ -441,6 +476,7 @@
|
||||
:default-required-fields="defaultRequiredFields"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:with-fields-detection="withFieldsDetection"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
@@ -448,6 +484,7 @@
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@select-submitter="selectedSubmitter = $event"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-drag="dragField = $event"
|
||||
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
|
||||
@@ -502,7 +539,7 @@ import DocumentPreview from './preview'
|
||||
import DocumentControls from './controls'
|
||||
import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
@@ -528,6 +565,7 @@ export default {
|
||||
Contenteditable,
|
||||
IconUsersPlus,
|
||||
IconChevronDown,
|
||||
IconDownload,
|
||||
IconAdjustments,
|
||||
IconEye,
|
||||
IconDeviceFloppy
|
||||
@@ -575,6 +613,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withDownload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -610,6 +653,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withAddPageButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -766,6 +814,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
onlyDefinedFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -786,6 +839,7 @@ export default {
|
||||
return {
|
||||
documentRefs: [],
|
||||
isBreakpointLg: false,
|
||||
isDownloading: false,
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
selectedSubmitter: null,
|
||||
@@ -944,6 +998,75 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
toRaw,
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/documents`).then(async (response) => {
|
||||
if (response.ok) {
|
||||
const urls = await response.json()
|
||||
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
|
||||
const isSafariIos = isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
|
||||
if (isSafariIos && urls.length > 1) {
|
||||
this.downloadSafariIos(urls)
|
||||
} else {
|
||||
this.downloadUrls(urls)
|
||||
}
|
||||
} else {
|
||||
alert(this.t('failed_to_download_files'))
|
||||
}
|
||||
})
|
||||
},
|
||||
downloadUrls (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob.slice(0, blob.size, 'application/octet-stream'))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
return link
|
||||
})
|
||||
})
|
||||
|
||||
Promise.all(fileRequests).then((links) => {
|
||||
links.forEach((link, index) => {
|
||||
setTimeout(() => {
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(link.href)
|
||||
}, index * 50)
|
||||
})
|
||||
}).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
onDragover (e) {
|
||||
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
|
||||
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
|
||||
@@ -1757,8 +1880,9 @@ export default {
|
||||
},
|
||||
onDocumentReplace (data) {
|
||||
const { replaceSchemaItem, schema, documents } = data
|
||||
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem
|
||||
|
||||
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...replaceSchemaItem, ...schema[0] })
|
||||
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
|
||||
this.template.documents.push(...documents)
|
||||
|
||||
if (data.fields) {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
dir="auto"
|
||||
:contenteditable="editable"
|
||||
style="min-width: 2px"
|
||||
:class="iconInline ? 'inline' : 'block'"
|
||||
class="peer outline-none focus:block"
|
||||
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
|
||||
class="peer outline-none"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter.prevent="blurContenteditable"
|
||||
@focus="$emit('focus', $event)"
|
||||
@@ -18,22 +18,19 @@
|
||||
{{ value }}
|
||||
</span>
|
||||
<span
|
||||
v-if="withRequired"
|
||||
title="Required"
|
||||
class="text-red-500 peer-focus:hidden"
|
||||
@click="focusContenteditable"
|
||||
class="relative inline"
|
||||
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
|
||||
>
|
||||
*
|
||||
<IconPencil
|
||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle pl-1"
|
||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||
:title="t('edit')"
|
||||
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
|
||||
:width="iconWidth + 4"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||
/>
|
||||
</span>
|
||||
<IconPencil
|
||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||
:title="t('edit')"
|
||||
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:width="iconWidth"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,7 +59,12 @@ export default {
|
||||
required: false,
|
||||
default: 30
|
||||
},
|
||||
withRequired: {
|
||||
hideIcon: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
floatIcon: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
:for="inputId"
|
||||
:class="{ 'opacity-50': isLoading, 'hover:bg-base-200/50': withHoverClass && !isDragEntering, 'bg-base-200/50 border-base-content/30': isDragEntering }"
|
||||
>
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center pointer-events-none">
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
class="animate-spin"
|
||||
@@ -40,6 +40,15 @@
|
||||
>
|
||||
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
|
||||
</div>
|
||||
<button
|
||||
v-if="withGoogleDrive"
|
||||
class="flex items-center text-sm mt-2 pointer-events-auto"
|
||||
@click.stop.prevent="$emit('click-google-drive')"
|
||||
>
|
||||
<span>{{ t('or_add_from') }}</span>
|
||||
<IconBrandGoogleDrive class="w-4 h-4 inline-block ml-1" />
|
||||
<span class="ml-1 font-medium hover:underline">Google Drive</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
@@ -62,7 +71,7 @@
|
||||
|
||||
<script>
|
||||
import Upload from './upload'
|
||||
import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FileDropzone',
|
||||
@@ -71,7 +80,8 @@ export default {
|
||||
IconCloudUpload,
|
||||
IconInnerShadowTop,
|
||||
IconFileSymlink,
|
||||
IconFiles
|
||||
IconFiles,
|
||||
IconBrandGoogleDrive
|
||||
},
|
||||
inject: ['baseFetch', 't'],
|
||||
props: {
|
||||
@@ -99,6 +109,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -110,7 +125,7 @@ export default {
|
||||
default: 'image/*, application/pdf, application/zip'
|
||||
}
|
||||
},
|
||||
emits: ['success', 'error', 'loading'],
|
||||
emits: ['success', 'error', 'loading', 'click-google-drive'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
>
|
||||
<span class="py-1 flex items-center">
|
||||
<span
|
||||
class="rounded-full w-3 h-3 ml-1 mr-3"
|
||||
class="rounded-full w-3 h-3 ml-1 mr-3 flex-shrink-0"
|
||||
:class="colors[index % colors.length]"
|
||||
/>
|
||||
<span>
|
||||
@@ -100,7 +100,7 @@
|
||||
class="cursor-pointer text-base-100 flex h-full items-center justify-center"
|
||||
>
|
||||
<button
|
||||
class="mx-1 w-3 h-3 rounded-full"
|
||||
class="mx-1 w-3 h-3 rounded-full flex-shrink-0"
|
||||
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
|
||||
/>
|
||||
</label>
|
||||
@@ -108,11 +108,11 @@
|
||||
v-else
|
||||
ref="label"
|
||||
tabindex="0"
|
||||
class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between"
|
||||
class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between items-center"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
:class="colors[submitters.indexOf(selectedSubmitter) % colors.length]"
|
||||
/>
|
||||
<Contenteditable
|
||||
@@ -125,7 +125,7 @@
|
||||
@update:model-value="$emit('name-change', selectedSubmitter)"
|
||||
/>
|
||||
</div>
|
||||
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
|
||||
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 justify-center rounded flex-shrink-0">
|
||||
<component
|
||||
:is="editable ? 'IconPlus' : 'IconChevronDown'"
|
||||
width="18"
|
||||
@@ -153,7 +153,7 @@
|
||||
>
|
||||
<span class="py-1 flex items-center">
|
||||
<span
|
||||
class="rounded-full w-3 h-3 ml-1 mr-3"
|
||||
class="rounded-full w-3 h-3 ml-1 mr-3 flex-shrink-0"
|
||||
:class="colors[index % colors.length]"
|
||||
/>
|
||||
<span>
|
||||
@@ -164,7 +164,7 @@
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="flex"
|
||||
>
|
||||
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
|
||||
<div class="flex-col pr-1 flex invisible group-hover:visible -mt-1 h-0">
|
||||
<button
|
||||
:title="t('up')"
|
||||
class="relative w-2"
|
||||
@@ -184,7 +184,7 @@
|
||||
</div>
|
||||
<button
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="hidden group-hover:block px-2"
|
||||
class="invisible group-hover:visible px-2"
|
||||
@click.prevent.stop="remove(submitter)"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
|
||||
@@ -208,6 +208,43 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsDetection && editable && fields.length < 2"
|
||||
class="my-2"
|
||||
>
|
||||
<button
|
||||
class="btn w-full"
|
||||
:class="{ 'bg-base-300': fieldPagesLoaded !== null }"
|
||||
@click="fieldPagesLoaded !== null ? null : detectFields()"
|
||||
>
|
||||
<template v-if="fieldPagesLoaded !== null">
|
||||
<IconInnerShadowTop
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="analyzingProgress"
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<IconSparkles width="22" />
|
||||
<span
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ t('autodetect_fields') }}
|
||||
</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
|
||||
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
|
||||
@@ -231,7 +268,7 @@
|
||||
import Field from './field'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
|
||||
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
@@ -240,11 +277,13 @@ export default {
|
||||
Field,
|
||||
FieldType,
|
||||
IconCirclePlus,
|
||||
IconSparkles,
|
||||
IconInnerShadowTop,
|
||||
FieldSubmitter,
|
||||
IconDrag,
|
||||
IconLock
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef'],
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
@@ -255,6 +294,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -328,15 +372,22 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
|
||||
data () {
|
||||
return {
|
||||
fieldPagesLoaded: null,
|
||||
analyzingProgress: 0,
|
||||
defaultFieldsSearch: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
numberOfPages () {
|
||||
return this.template.documents.reduce((acc, doc) => {
|
||||
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
|
||||
}, 0)
|
||||
},
|
||||
isShowFieldSearch () {
|
||||
if (this.withFieldsSearch === false) {
|
||||
return false
|
||||
@@ -389,6 +440,84 @@ export default {
|
||||
|
||||
this.$emit('set-drag', field)
|
||||
},
|
||||
detectFields () {
|
||||
const fields = []
|
||||
|
||||
this.fieldPagesLoaded = 0
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n\n')
|
||||
|
||||
buffer = lines.pop()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.replace(/^data: /, '')
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
if (data.error) {
|
||||
if ((data.fields || fields).length) {
|
||||
this.template.fields = data.fields || fields
|
||||
|
||||
this.save()
|
||||
} else {
|
||||
alert(data.error)
|
||||
}
|
||||
|
||||
break
|
||||
} else if (data.analyzing) {
|
||||
this.analyzingProgress = data.progress
|
||||
} else if (data.completed) {
|
||||
this.fieldPagesLoaded = null
|
||||
|
||||
if (data.submitters) {
|
||||
this.template.submitters = data.submitters
|
||||
this.$emit('select-submitter', this.template.submitters[0])
|
||||
}
|
||||
|
||||
this.template.fields = data.fields || fields
|
||||
|
||||
this.save()
|
||||
|
||||
break
|
||||
} else if (data.fields) {
|
||||
data.fields.forEach((f) => {
|
||||
if (!f.submitter_uuid) {
|
||||
f.submitter_uuid = this.template.submitters[0].uuid
|
||||
}
|
||||
})
|
||||
|
||||
this.fieldPagesLoaded += 1
|
||||
|
||||
fields.push(...data.fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error in streaming message: ', error)
|
||||
}).finally(() => {
|
||||
this.fieldPagesLoaded = null
|
||||
this.analyzingProgress = null
|
||||
this.isFieldsLoading = false
|
||||
})
|
||||
},
|
||||
setDragPlaceholder (event) {
|
||||
this.$emit('set-drag-placeholder', {
|
||||
offsetX: event.offsetX,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div
|
||||
class="dropdown"
|
||||
:class="{ 'dropdown-open': isLoading }"
|
||||
>
|
||||
<label tabindex="0">
|
||||
<IconBrandGoogleDrive
|
||||
width="19"
|
||||
class="inline-block mr-1 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
:style="{ backgroundColor }"
|
||||
class="dropdown-content z-[1] shadow menu rounded-box"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
:href="`https://drive.google.com/file/d/${googleDriveFileId}/view?usp=sharing`"
|
||||
data-turbo="false"
|
||||
target="_blank"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconExternalLink class="w-4 h-4 flex-shrink-0" />
|
||||
<span>{{ t('view') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
form="sync_form"
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<IconRefresh
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
:class="{ 'animate-spin': isLoading }"
|
||||
/>
|
||||
<span>{{ message }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<form
|
||||
id="sync_form"
|
||||
ref="form"
|
||||
class="hidden"
|
||||
@submit.prevent="upload({ path: uploadUrl })"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="input"
|
||||
:value="googleDriveFileId"
|
||||
name="google_drive_file_ids[]"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Upload from './upload'
|
||||
import { IconRefresh, IconBrandGoogleDrive, IconExternalLink } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'GoogleDriveDocumentSettings',
|
||||
components: {
|
||||
IconRefresh,
|
||||
IconBrandGoogleDrive,
|
||||
IconExternalLink
|
||||
},
|
||||
inject: ['baseFetch', 't', 'backgroundColor'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
googleDriveFileId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['success'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
},
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/google_drive_documents`
|
||||
},
|
||||
message () {
|
||||
if (this.isLoading) {
|
||||
return this.t('syncing')
|
||||
} else {
|
||||
return this.t('sync')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
upload: Upload.methods.upload
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,9 @@
|
||||
const en = {
|
||||
analyzing_: 'Analyzing...',
|
||||
download: 'Download',
|
||||
downloading_: 'Downloading...',
|
||||
view: 'View',
|
||||
autodetect_fields: 'Autodetect fields',
|
||||
payment_link: 'Payment link',
|
||||
strikeout: 'Strikeout',
|
||||
draw_strikethrough_the_document: 'Draw strikethrough the document',
|
||||
@@ -176,10 +181,18 @@ const en = {
|
||||
and: 'and',
|
||||
or: 'or',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document',
|
||||
start_tour: 'Start Tour'
|
||||
start_tour: 'Start Tour',
|
||||
or_add_from: 'Or add from',
|
||||
sync: 'Sync',
|
||||
syncing: 'Syncing...'
|
||||
}
|
||||
|
||||
const es = {
|
||||
autodetect_fields: 'Autodetectar campos',
|
||||
analyzing_: 'Analizando...',
|
||||
download: 'Descargar',
|
||||
downloading_: 'Descargando...',
|
||||
view: 'Vista',
|
||||
payment_link: 'Enlace de pago',
|
||||
strikeout: 'Tachar',
|
||||
draw_strikethrough_the_document: 'Dibujar una línea de tachado en el documento',
|
||||
@@ -357,10 +370,18 @@ const es = {
|
||||
and: 'y',
|
||||
or: 'o',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
|
||||
start_tour: 'Iniciar guía'
|
||||
start_tour: 'Iniciar guía',
|
||||
or_add_from: 'O agregar desde',
|
||||
sync: 'Sincronizar',
|
||||
syncing: 'Sincronizando...'
|
||||
}
|
||||
|
||||
const it = {
|
||||
autodetect_fields: 'Rileva campi',
|
||||
analyzing_: 'Analisi...',
|
||||
download: 'Scarica',
|
||||
downloading_: 'Download in corso...',
|
||||
view: 'Vista',
|
||||
payment_link: 'Link di pagamento',
|
||||
strikeout: 'Barrato',
|
||||
draw_strikethrough_the_document: 'Disegna una linea barrata sul documento',
|
||||
@@ -538,10 +559,18 @@ const it = {
|
||||
and: 'e',
|
||||
or: 'o',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
|
||||
start_tour: 'Inizia il tour'
|
||||
start_tour: 'Inizia il tour',
|
||||
or_add_from: 'O aggiungi da',
|
||||
sync: 'Sincronizza',
|
||||
syncing: 'Sincronizzazione...'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
autodetect_fields: 'Detectar campos',
|
||||
analyzing_: 'Analisando...',
|
||||
download: 'Baixar',
|
||||
downloading_: 'Baixando...',
|
||||
view: 'Visualizar',
|
||||
payment_link: 'Link de pagamento',
|
||||
strikeout: 'Tachado',
|
||||
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
|
||||
@@ -719,94 +748,108 @@ const pt = {
|
||||
and: 'e',
|
||||
or: 'ou',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
|
||||
start_tour: 'Iniciar tour'
|
||||
start_tour: 'Iniciar tour',
|
||||
or_add_from: 'Ou adicionar de',
|
||||
sync: 'Sincronizar',
|
||||
syncing: 'Sincronizando...'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
autodetect_fields: 'Détecter les champs',
|
||||
analyzing_: 'Analyse...',
|
||||
download: 'Télécharger',
|
||||
downloading_: 'Téléchargement...',
|
||||
view: 'Voir',
|
||||
payment_link: 'Lien de paiement',
|
||||
strikeout: 'Barrer',
|
||||
draw_strikethrough_the_document: 'Tracer une ligne de suppression sur le document',
|
||||
strikeout: 'Rature',
|
||||
draw_strikethrough_the_document: 'Tracer une rature sur le document',
|
||||
quantity: 'Quantité',
|
||||
prefillable: 'Pré-remplissable',
|
||||
signature_id: 'ID de signature',
|
||||
error_message: 'Message d\'erreur',
|
||||
prefillable: 'Préremplissable',
|
||||
signature_id: 'Identifiant de signature',
|
||||
error_message: "Message d'erreur",
|
||||
length: 'Longueur',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
date_signed: 'Date actuelle',
|
||||
font: 'Police',
|
||||
party: 'Partie',
|
||||
date_signed: 'Date de signature',
|
||||
method: 'Méthode',
|
||||
reorder_fields: 'Réorganiser les champs',
|
||||
verify_id: "Vérifier l'ID",
|
||||
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) avec le fournisseur de confiance. Cliquez pour en savoir plus.',
|
||||
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) auprès du fournisseur de confiance. Cliquez pour en savoir plus.',
|
||||
editable: 'Modifiable',
|
||||
recurrent: 'Récurrent',
|
||||
one_off: 'Ponctuel',
|
||||
editable: 'Éditable',
|
||||
search_field: 'Champ de recherche',
|
||||
field_not_found: 'Champ non trouvé',
|
||||
field_not_found: 'Champ introuvable',
|
||||
clear: 'Effacer',
|
||||
type_value: 'Tapez la valeur',
|
||||
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
|
||||
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléchargé contient des champs. Les conserver ou les supprimer?',
|
||||
keep: 'Conserver',
|
||||
align: 'Aligner',
|
||||
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
|
||||
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?',
|
||||
keep: 'Conserver',
|
||||
left: 'Gauche',
|
||||
heading: 'En-tête',
|
||||
heading: 'Titre',
|
||||
validation: 'Validation',
|
||||
add_blank_page: 'Ajouter une page blanche',
|
||||
right: 'Droite',
|
||||
add_condition: 'Ajouter une condition',
|
||||
center: 'Centre',
|
||||
with_logo: 'Avec logo',
|
||||
description: 'Description',
|
||||
display_title: "Titre d'affichage",
|
||||
unchecked: 'Non coché',
|
||||
display_title: 'Afficher le titre',
|
||||
with_logo: 'Avec logo',
|
||||
unchecked: 'Décoché',
|
||||
price: 'Prix',
|
||||
type_value: 'Saisir une valeur',
|
||||
equal: 'Égal',
|
||||
not_equal: 'Non égal',
|
||||
not_equal: 'Différent',
|
||||
contains: 'Contient',
|
||||
signing_date: 'Date de signature',
|
||||
does_not_contain: 'Ne contient pas',
|
||||
not_empty: 'Non vide',
|
||||
empty: 'Vide',
|
||||
select_field_: 'Sélectionner un champ...',
|
||||
select_value_: 'Sélectionner une valeur...',
|
||||
remove_condition: 'Supprimer la condition',
|
||||
condition: 'Condition',
|
||||
formula: 'Formule',
|
||||
edit: 'Éditer',
|
||||
add_condition: 'Ajouter une condition',
|
||||
are_you_sure_: 'Êtes-vous sûr ?',
|
||||
sign_yourself: 'Signer vous-même',
|
||||
set_signing_date: 'Définir la date de signature',
|
||||
signing_date: 'Date de signature',
|
||||
send: 'Envoyer',
|
||||
remove: 'Supprimer',
|
||||
edit: 'Modifier',
|
||||
settings: 'Paramètres',
|
||||
up: 'Haut',
|
||||
down: 'Bas',
|
||||
set_signing_date: 'Définir la date de signature',
|
||||
are_you_sure_: 'Êtes-vous sûr?',
|
||||
sign_yourself: 'Signez-vous',
|
||||
checked: 'Coché',
|
||||
send: 'Envoyer',
|
||||
remove: 'Supprimer',
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
draw_field_on_the_document: 'Dessiner un champ sur le document',
|
||||
click_to_upload: 'Cliquez pour télécharger',
|
||||
any: "N'importe lequel",
|
||||
drawn: 'Dessiné',
|
||||
drawn_or_typed: 'Dessiné ou tapé',
|
||||
drawn_or_upload: 'Dessiné ou téléversé',
|
||||
upload: 'Téléverser',
|
||||
formula: 'Formule',
|
||||
typed: 'Tapé',
|
||||
draw_field_on_the_document: 'Dessinez un champ sur le document',
|
||||
click_to_upload: 'Cliquez pour téléverser',
|
||||
or_drag_and_drop_files: 'ou faites glisser-déposer des fichiers',
|
||||
uploading: 'Téléchargement en cours',
|
||||
processing_: 'Traitement en cours...',
|
||||
add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images',
|
||||
add_documents_or_images: 'Ajoutez des documents ou des images',
|
||||
add_a_new_document: 'Adicionar um novo documento',
|
||||
uploading: 'Téléversement en cours',
|
||||
processing_: 'Traitement...',
|
||||
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
|
||||
add_documents_or_images: 'Ajouter des documents ou des images',
|
||||
add_a_new_document: 'Ajouter un nouveau document',
|
||||
replace_existing_document: 'Remplacer le document existant',
|
||||
clone_and_replace_documents: 'Cloner et remplacer les documents',
|
||||
required: 'Requis',
|
||||
clone_and_replace_documents: 'Cloner et remplacer des documents',
|
||||
required: 'Obligatoire',
|
||||
default_value: 'Valeur par défaut',
|
||||
format: 'Format',
|
||||
read_only: 'Lecture seule',
|
||||
page: 'Page',
|
||||
draw_new_area: 'Dessiner une nouvelle zone',
|
||||
draw_new_area: 'Dessiner une zone',
|
||||
copy_to_all_pages: 'Copier sur toutes les pages',
|
||||
add_option: 'Ajouter une option',
|
||||
option: 'Option',
|
||||
options: 'Options',
|
||||
condition: 'Condition',
|
||||
first_party: 'Première partie',
|
||||
second_party: 'Deuxième partie',
|
||||
third_party: 'Troisième partie',
|
||||
@@ -817,27 +860,27 @@ const fr = {
|
||||
eighth_party: 'Huitième partie',
|
||||
ninth_party: 'Neuvième partie',
|
||||
tenth_party: 'Dixième partie',
|
||||
eleventh_party: 'Onzième Parti',
|
||||
twelfth_party: 'Douzième Parti',
|
||||
thirteenth_party: 'Treizième Parti',
|
||||
fourteenth_party: 'Quatorzième Parti',
|
||||
fifteenth_party: 'Quinzième Parti',
|
||||
sixteenth_party: 'Seizième Parti',
|
||||
seventeenth_party: 'Dix-septième Parti',
|
||||
eighteenth_party: 'Dix-huitième Parti',
|
||||
nineteenth_party: 'Dix-Neuvième Fête',
|
||||
twentieth_party: 'Vingtième Fête',
|
||||
eleventh_party: 'Onzième partie',
|
||||
twelfth_party: 'Douzième partie',
|
||||
thirteenth_party: 'Treizième partie',
|
||||
fourteenth_party: 'Quatorzième partie',
|
||||
fifteenth_party: 'Quinzième partie',
|
||||
sixteenth_party: 'Seizième partie',
|
||||
seventeenth_party: 'Dix-septième partie',
|
||||
eighteenth_party: 'Dix-huitième partie',
|
||||
nineteenth_party: 'Dix-neuvième partie',
|
||||
twentieth_party: 'Vingtième partie',
|
||||
draw: 'Dessiner',
|
||||
add: 'Ajouter',
|
||||
or_add_field_without_drawing: 'Ou ajoutez un champ sans dessiner',
|
||||
or_add_field_without_drawing: 'Ou ajouter un champ sans le dessiner',
|
||||
text: 'Texte',
|
||||
number: 'Nombre',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
date: 'Date',
|
||||
number: 'Numéro',
|
||||
image: 'Image',
|
||||
file: 'Fichier',
|
||||
select: 'Choisir',
|
||||
select: 'Sélection',
|
||||
checkbox: 'Coche',
|
||||
multiple: 'Multiple',
|
||||
radio: 'Radio',
|
||||
@@ -845,40 +888,34 @@ const fr = {
|
||||
stamp: 'Tampon',
|
||||
payment: 'Paiement',
|
||||
phone: 'Téléphone',
|
||||
text_field: 'Champ de Texte',
|
||||
signature_field: 'Champ de Signature',
|
||||
initials_field: "Champ d'Initiales",
|
||||
date_field: 'Champ de Date',
|
||||
number_field: 'Champ de Numéro',
|
||||
image_field: "Champ d'Image",
|
||||
file_field: 'Champ de Fichier',
|
||||
select_field: 'Champ de Choix',
|
||||
checkbox_field: 'Champ de Coche',
|
||||
multiple_field: 'Champ de Choix Multiple',
|
||||
radio_field: 'Champ de Groupe Radio',
|
||||
cells_field: 'Champ de Cellules',
|
||||
stamp_field: 'Champ de Tampon',
|
||||
payment_field: 'Champ de Paiement',
|
||||
phone_field: 'Champ de Téléphone',
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Dessinez un champ texte sur la page avec une souris',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Glissez et déposez tout autre type de champ sur la page',
|
||||
text_field: 'Champ de texte',
|
||||
signature_field: 'Champ de signature',
|
||||
initials_field: "Champ d'initiales",
|
||||
date_field: 'Champ de date',
|
||||
number_field: 'Champ de numéro',
|
||||
image_field: "Champ d'image",
|
||||
file_field: 'Champ de fichier',
|
||||
select_field: 'Champ de choix',
|
||||
checkbox_field: 'Champ de coche',
|
||||
multiple_field: 'Champ de choix multiple',
|
||||
radio_field: 'Champ de groupe radio',
|
||||
cells_field: 'Champ de cellules',
|
||||
stamp_field: 'Champ de tampon',
|
||||
payment_field: 'Champ de paiement',
|
||||
phone_field: 'Champ de téléphone',
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Dessinez un champ de texte sur la page avec la souris',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Glissez-déposez tout autre type de champ sur la page',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Cliquez sur le type de champ ci-dessus pour commencer à le dessiner',
|
||||
please_draw_fields_to_prepare_the_document: 'Veuillez dessiner les champs pour préparer le document.',
|
||||
please_draw_fields_to_prepare_the_document: 'Veuillez dessiner des champs pour préparer le document.',
|
||||
only_pdf_and_images_are_supported: 'Seuls les PDF et les images sont pris en charge.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Débloquez le champ de numéro de téléphone vérifié par SMS avec un plan payant. Utilisez un champ texte pour les numéros de téléphone sans vérification.',
|
||||
available_only_in_pro: 'Disponible uniquement en Pro',
|
||||
failed_to_download_files: 'Échec du téléchargement des fichiers',
|
||||
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Veuillez ajouter des champs pour {submitter_name} ou retirer {submitter_name} si ce n\'est pas nécessaire.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Débloquez le champ de numéro de téléphone vérifié par SMS avec un forfait payant. Utilisez un champ texte pour les numéros sans vérification.',
|
||||
available_only_in_pro: 'Disponible uniquement dans la version Pro',
|
||||
failed_to_download_files: 'Impossible de télécharger les fichiers',
|
||||
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: "Veuillez ajouter des champs pour {submitter_name}. Ou supprimez {submitter_name} si ce n'est pas nécessaire.",
|
||||
draw_field: 'Dessiner le champ {field}',
|
||||
replace: 'Remplacer',
|
||||
uploading_: 'Téléchargement en cours...',
|
||||
add_document: 'Télécharger',
|
||||
any: 'Tout',
|
||||
drawn: 'Dessiné',
|
||||
drawn_or_typed: 'Dessiné ou Tapé',
|
||||
drawn_or_upload: 'Dessiné ou Téléchargé',
|
||||
upload: 'Téléverser',
|
||||
typed: 'Tapé',
|
||||
uploading_: 'Téléversement...',
|
||||
add_document: 'Téléverser',
|
||||
none: 'Aucun',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
@@ -889,90 +926,103 @@ const fr = {
|
||||
numbers_only: 'Chiffres uniquement',
|
||||
letters_only: 'Lettres uniquement',
|
||||
regexp_validation: 'Validation par expression régulière',
|
||||
enter_pdf_password: 'Entrez le mot de passe PDF',
|
||||
enter_pdf_password: 'Saisir le mot de passe du PDF',
|
||||
wrong_password: 'Mot de passe incorrect',
|
||||
currency: 'Devise',
|
||||
save_and_preview: 'Enregistrer et prévisualiser',
|
||||
preferences: 'Préférences',
|
||||
available_in_pro: 'Disponible en version Pro',
|
||||
available_in_pro: 'Disponible dans la version Pro',
|
||||
some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.',
|
||||
learn_more: 'En savoir plus',
|
||||
and: 'et',
|
||||
or: 'ou',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Lancez une visite rapide pour apprendre à créer et envoyer votre premier document.',
|
||||
start_tour: 'Démarrer'
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
|
||||
start_tour: 'Démarrer',
|
||||
or_add_from: 'Ou ajouter depuis',
|
||||
sync: 'Synchroniser',
|
||||
syncing: 'Synchronisation...'
|
||||
}
|
||||
|
||||
const de = {
|
||||
autodetect_fields: 'Felder erkennen',
|
||||
analyzing_: 'Analysiere...',
|
||||
download: 'Download',
|
||||
downloading_: 'Download...',
|
||||
view: 'Anzeigen',
|
||||
payment_link: 'Zahlungslink',
|
||||
strikeout: 'Streichung',
|
||||
draw_strikethrough_the_document: 'Ziehe eine Streichung auf das Dokument',
|
||||
strikeout: 'Durchstreichen',
|
||||
draw_strikethrough_the_document: 'Durchstreichung im Dokument zeichnen',
|
||||
quantity: 'Menge',
|
||||
prefillable: 'Vorausfüllbar',
|
||||
signature_id: 'Signatur-ID',
|
||||
error_message: 'Fehlermeldung',
|
||||
length: 'Länge',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
date_now: 'Akt. Datum',
|
||||
min: 'Min.',
|
||||
max: 'Max.',
|
||||
font: 'Schriftart',
|
||||
party: 'Partei',
|
||||
method: 'Verfahren',
|
||||
date_signed: 'Unterzeichnungsdatum',
|
||||
method: 'Methode',
|
||||
reorder_fields: 'Felder neu anordnen',
|
||||
verify_id: 'ID überprüfen',
|
||||
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Erhalten Sie eine qualifizierte elektronische Signatur (QeS) beim vertrauenswürdigen Anbieter. Klicken Sie hier, um mehr zu erfahren.',
|
||||
wiederkehrend: 'Wiederkehrend',
|
||||
einmalig: 'Einmalig',
|
||||
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Qualifizierte elektronische Signatur (QES) beim vertrauenswürdigen Anbieter einholen. Klicken Sie, um mehr zu erfahren.',
|
||||
editable: 'Bearbeitbar',
|
||||
recurrent: 'Wiederkehrend',
|
||||
one_off: 'Einmalig',
|
||||
search_field: 'Suchfeld',
|
||||
field_not_found: 'Feld nicht gefunden',
|
||||
clear: 'Löschen',
|
||||
type_value: 'Wert eingeben',
|
||||
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
|
||||
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Behalten oder entfernen?',
|
||||
keep: 'Behalten',
|
||||
clear: 'Leeren',
|
||||
align: 'Ausrichten',
|
||||
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
|
||||
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?',
|
||||
keep: 'Beibehalten',
|
||||
left: 'Links',
|
||||
heading: 'Überschrift',
|
||||
validation: 'Validierung',
|
||||
add_blank_page: 'Leere Seite hinzufügen',
|
||||
right: 'Rechts',
|
||||
add_condition: 'Bedingung hinzufügen',
|
||||
center: 'Zentriert',
|
||||
with_logo: 'Mit Logo',
|
||||
description: 'Beschreibung',
|
||||
display_title: 'Anzeigetitel',
|
||||
unchecked: 'Nicht überprüft',
|
||||
with_logo: 'Mit Logo',
|
||||
unchecked: 'Nicht markiert',
|
||||
price: 'Preis',
|
||||
type_value: 'Wert eingeben',
|
||||
equal: 'Gleich',
|
||||
not_equal: 'Ungleich',
|
||||
contains: 'Enthält',
|
||||
does_not_contain: 'Enthält nicht',
|
||||
not_empty: 'Nicht leer',
|
||||
empty: 'Leer',
|
||||
signing_date: 'Signaturdatum',
|
||||
select_field_: 'Feld auswählen...',
|
||||
select_value_: 'Wert auswählen...',
|
||||
remove_condition: 'Bedingung entfernen',
|
||||
condition: 'Bedingung',
|
||||
formula: 'Formel',
|
||||
add_condition: 'Bedingung hinzufügen',
|
||||
are_you_sure_: 'Sind Sie sicher?',
|
||||
sign_yourself: 'Selbst unterschreiben',
|
||||
set_signing_date: 'Unterzeichnungsdatum festlegen',
|
||||
signing_date: 'Unterzeichnungsdatum',
|
||||
send: 'Senden',
|
||||
remove: 'Entfernen',
|
||||
edit: 'Bearbeiten',
|
||||
settings: 'Einstellungen',
|
||||
up: 'Nach oben',
|
||||
down: 'Nach unten',
|
||||
set_signing_date: 'Unterschriftdatum festlegen',
|
||||
are_you_sure_: 'Bist du sicher?',
|
||||
sign_yourself: 'Unterschreiben Sie selbst',
|
||||
checked: 'Ausgewählt',
|
||||
send: 'Senden',
|
||||
remove: 'Entfernen',
|
||||
checked: 'Markiert',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
draw_field_on_the_document: 'Ein Feld auf dem Dokument zeichnen',
|
||||
click_to_upload: 'Klicken Sie zum Hochladen',
|
||||
or_drag_and_drop_files: 'oder Dateien hierher ziehen',
|
||||
uploading: 'Hochladen',
|
||||
processing_: 'Verarbeitung...',
|
||||
any: 'Beliebig',
|
||||
drawn: 'Gezeichnet',
|
||||
drawn_or_typed: 'Gezeichnet oder getippt',
|
||||
drawn_or_upload: 'Gezeichnet oder hochgeladen',
|
||||
upload: 'Hochladen',
|
||||
formula: 'Formel',
|
||||
typed: 'Getippt',
|
||||
draw_field_on_the_document: 'Ein Feld im Dokument zeichnen',
|
||||
click_to_upload: 'Zum Hochladen klicken',
|
||||
or_drag_and_drop_files: 'oder Dateien per Drag & Drop ablegen',
|
||||
uploading: 'Hochladen...',
|
||||
processing_: 'Verarbeiten...',
|
||||
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
|
||||
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
|
||||
add_a_new_document: 'Neues Dokument hinzufügen',
|
||||
@@ -983,11 +1033,12 @@ const de = {
|
||||
format: 'Format',
|
||||
read_only: 'Nur lesen',
|
||||
page: 'Seite',
|
||||
draw_new_area: 'Neuen Bereich zeichnen',
|
||||
draw_new_area: 'Bereich zeichnen',
|
||||
copy_to_all_pages: 'Auf alle Seiten kopieren',
|
||||
add_option: 'Option hinzufügen',
|
||||
option: 'Option',
|
||||
options: 'Options',
|
||||
options: 'Optionen',
|
||||
condition: 'Bedingung',
|
||||
first_party: 'Erste Partei',
|
||||
second_party: 'Zweite Partei',
|
||||
third_party: 'Dritte Partei',
|
||||
@@ -1006,21 +1057,21 @@ const de = {
|
||||
sixteenth_party: 'Sechzehnte Partei',
|
||||
seventeenth_party: 'Siebzehnte Partei',
|
||||
eighteenth_party: 'Achtzehnte Partei',
|
||||
nineteenth_party: 'Neunzehnte Party',
|
||||
twentieth_party: 'Zwanzigste Party',
|
||||
nineteenth_party: 'Neunzehnte Partei',
|
||||
twentieth_party: 'Zwanzigste Partei',
|
||||
draw: 'Zeichnen',
|
||||
add: 'Hinzufügen',
|
||||
or_add_field_without_drawing: 'Oder Feld ohne Zeichnung hinzufügen',
|
||||
or_add_field_without_drawing: 'Oder Feld ohne Zeichnen hinzufügen',
|
||||
text: 'Text',
|
||||
number: 'Zahl',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Bild',
|
||||
file: 'Datei',
|
||||
select: 'Auswählen',
|
||||
select: 'Auswahl',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Mehrere',
|
||||
multiple: 'Mehrfach',
|
||||
radio: 'Radio',
|
||||
cells: 'Zellen',
|
||||
stamp: 'Stempel',
|
||||
@@ -1034,32 +1085,26 @@ const de = {
|
||||
image_field: 'Bildfeld',
|
||||
file_field: 'Dateifeld',
|
||||
select_field: 'Auswahlfeld',
|
||||
checkbox_field: 'Checkbox-Feld',
|
||||
multiple_field: 'Mehrfach-Auswahlfeld',
|
||||
radio_field: 'Radio-Gruppenfeld',
|
||||
checkbox_field: 'Checkboxfeld',
|
||||
multiple_field: 'Mehrfachauswahlfeld',
|
||||
radio_field: 'Radiogruppe',
|
||||
cells_field: 'Zellenfeld',
|
||||
stamp_field: 'Stempelfeld',
|
||||
payment_field: 'Zahlungsfeld',
|
||||
phone_field: 'Telefonfeld',
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Textfeld auf der Seite mit der Maus zeichnen',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Ziehe und lasse einen anderen Feldtyp auf die Seite fallen',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Klicke auf den Feldtyp oben, um mit dem Zeichnen zu beginnen',
|
||||
please_draw_fields_to_prepare_the_document: 'Bitte zeichne Felder, um das Dokument vorzubereiten.',
|
||||
only_pdf_and_images_are_supported: 'Nur PDFs und Bilder werden unterstützt.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Schalte das SMS-verifizierte Telefonnummernfeld mit einem kostenpflichtigen Plan frei. Verwende das Textfeld für Telefonnummern ohne Verifizierung.',
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Ein Textfeld mit der Maus auf der Seite zeichnen',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Jeden anderen Feldtyp auf die Seite ziehen und ablegen',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Klicken Sie oben auf den Feldtyp, um mit dem Zeichnen zu beginnen',
|
||||
please_draw_fields_to_prepare_the_document: 'Bitte zeichnen Sie Felder, um das Dokument vorzubereiten.',
|
||||
only_pdf_and_images_are_supported: 'Nur PDF und Bilder werden unterstützt.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'SMS-verifiziertes Telefonnummernfeld mit kostenpflichtigem Plan freischalten. Verwenden Sie für Telefonnummern ohne Verifizierung das Textfeld.',
|
||||
available_only_in_pro: 'Nur in Pro verfügbar',
|
||||
failed_to_download_files: 'Fehler beim Herunterladen der Dateien',
|
||||
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Bitte füge Felder für {submitter_name} hinzu oder entferne {submitter_name}, falls nicht erforderlich.',
|
||||
draw_field: 'Feld {field} zeichnen',
|
||||
failed_to_download_files: 'Dateien konnten nicht heruntergeladen werden',
|
||||
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Bitte fügen Sie Felder für {submitter_name} hinzu oder entfernen Sie {submitter_name}, falls nicht benötigt.',
|
||||
draw_field: '{field}-Feld zeichnen',
|
||||
replace: 'Ersetzen',
|
||||
uploading_: 'Hochladen...',
|
||||
add_document: 'Hochladen',
|
||||
any: 'Jede',
|
||||
drawn: 'Gezeichnet',
|
||||
drawn_or_typed: 'Gezeichnet oder getippt',
|
||||
drawn_or_upload: 'Gezeichnet oder hochgeladen',
|
||||
upload: 'Upload',
|
||||
typed: 'Getippt',
|
||||
none: 'Keine',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
@@ -1069,19 +1114,211 @@ const de = {
|
||||
custom: 'Benutzerdefiniert',
|
||||
numbers_only: 'Nur Zahlen',
|
||||
letters_only: 'Nur Buchstaben',
|
||||
regexp_validation: 'Regulärer Ausdruck Überprüfung',
|
||||
enter_pdf_password: 'Geben Sie das PDF-Passwort ein',
|
||||
regexp_validation: 'RegExp-Validierung',
|
||||
enter_pdf_password: 'PDF-Passwort eingeben',
|
||||
wrong_password: 'Falsches Passwort',
|
||||
currency: 'Währung',
|
||||
save_and_preview: 'Speichern und Vorschau',
|
||||
preferences: 'Einstellungen',
|
||||
preferences: 'Voreinstellungen',
|
||||
available_in_pro: 'In Pro verfügbar',
|
||||
some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.',
|
||||
learn_more: 'Erfahren Sie mehr',
|
||||
learn_more: 'Mehr erfahren',
|
||||
and: 'und',
|
||||
or: 'oder',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starte eine kurze Tour, um zu lernen, wie du dein erstes Dokument erstellst und versendest.',
|
||||
start_tour: 'Starten'
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
|
||||
start_tour: 'Tour starten',
|
||||
or_add_from: 'Oder hinzufügen aus',
|
||||
sync: 'Synchronisieren',
|
||||
syncing: 'Synchronisiere...'
|
||||
}
|
||||
|
||||
export { en, es, it, pt, fr, de }
|
||||
const nl = {
|
||||
autodetect_fields: 'Velden detecteren',
|
||||
analyzing_: 'Analyseren...',
|
||||
download: 'Downloaden',
|
||||
downloading_: 'Downloaden...',
|
||||
view: 'Bekijken',
|
||||
payment_link: 'Betaallink',
|
||||
strikeout: 'Doorhalen',
|
||||
draw_strikethrough_the_document: 'Teken doorhaling in het document',
|
||||
quantity: 'Aantal',
|
||||
prefillable: 'Voorinvulbaar',
|
||||
signature_id: 'Handtekening-ID',
|
||||
error_message: 'Foutmelding',
|
||||
length: 'Lengte',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
font: 'Lettertype',
|
||||
party: 'Partij',
|
||||
date_signed: 'Datum ondertekend',
|
||||
method: 'Methode',
|
||||
reorder_fields: 'Velden herschikken',
|
||||
verify_id: 'ID verifiëren',
|
||||
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Verkrijg een gekwalificeerde elektronische handtekening (QeS) via de vertrouwde provider. Klik voor meer informatie.',
|
||||
editable: 'Bewerkbaar',
|
||||
recurrent: 'Terugkerend',
|
||||
one_off: 'Eenmalig',
|
||||
search_field: 'Zoekveld',
|
||||
field_not_found: 'Veld niet gevonden',
|
||||
clear: 'Wissen',
|
||||
align: 'Uitlijnen',
|
||||
add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan',
|
||||
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?',
|
||||
keep: 'Behouden',
|
||||
left: 'Links',
|
||||
heading: 'Koptekst',
|
||||
validation: 'Validatie',
|
||||
add_blank_page: 'Lege pagina toevoegen',
|
||||
right: 'Rechts',
|
||||
center: 'Centreren',
|
||||
description: 'Beschrijving',
|
||||
display_title: 'Titel weergeven',
|
||||
with_logo: 'Met logo',
|
||||
unchecked: 'Niet aangevinkt',
|
||||
price: 'Prijs',
|
||||
type_value: 'Typ waarde',
|
||||
equal: 'Gelijk aan',
|
||||
not_equal: 'Niet gelijk aan',
|
||||
contains: 'Bevat',
|
||||
does_not_contain: 'Bevat niet',
|
||||
not_empty: 'Niet leeg',
|
||||
empty: 'Leeg',
|
||||
select_field_: 'Veld selecteren...',
|
||||
select_value_: 'Waarde selecteren...',
|
||||
remove_condition: 'Voorwaarde verwijderen',
|
||||
add_condition: 'Voorwaarde toevoegen',
|
||||
are_you_sure_: 'Weet u het zeker?',
|
||||
sign_yourself: 'Zelf ondertekenen',
|
||||
set_signing_date: 'Ondertekeningsdatum instellen',
|
||||
signing_date: 'Ondertekeningsdatum',
|
||||
send: 'Verzenden',
|
||||
remove: 'Verwijderen',
|
||||
edit: 'Bewerken',
|
||||
settings: 'Instellingen',
|
||||
up: 'Omhoog',
|
||||
down: 'Omlaag',
|
||||
checked: 'Aangevinkt',
|
||||
save: 'Opslaan',
|
||||
cancel: 'Annuleren',
|
||||
any: 'Elke',
|
||||
drawn: 'Getekend',
|
||||
drawn_or_typed: 'Getekend of getypt',
|
||||
drawn_or_upload: 'Getekend of uploaden',
|
||||
upload: 'Uploaden',
|
||||
formula: 'Formule',
|
||||
typed: 'Getypt',
|
||||
draw_field_on_the_document: 'Teken een veld op het document',
|
||||
click_to_upload: 'Klik om te uploaden',
|
||||
or_drag_and_drop_files: 'of sleep bestanden hierheen',
|
||||
uploading: 'Uploaden',
|
||||
processing_: 'Bezig met verwerken...',
|
||||
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
|
||||
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
|
||||
add_a_new_document: 'Nieuw document toevoegen',
|
||||
replace_existing_document: 'Bestaand document vervangen',
|
||||
clone_and_replace_documents: 'Documenten klonen en vervangen',
|
||||
required: 'Vereist',
|
||||
default_value: 'Standaardwaarde',
|
||||
format: 'Formaat',
|
||||
read_only: 'Alleen-lezen',
|
||||
page: 'Pagina',
|
||||
draw_new_area: 'Nieuw gebied tekenen',
|
||||
copy_to_all_pages: 'Kopieer naar alle pag.',
|
||||
add_option: 'Optie toevoegen',
|
||||
option: 'Optie',
|
||||
options: 'Opties',
|
||||
condition: 'Voorwaarde',
|
||||
first_party: 'Eerste partij',
|
||||
second_party: 'Tweede partij',
|
||||
third_party: 'Derde partij',
|
||||
fourth_party: 'Vierde partij',
|
||||
fifth_party: 'Vijfde partij',
|
||||
sixth_party: 'Zesde partij',
|
||||
seventh_party: 'Zevende partij',
|
||||
eighth_party: 'Achtste partij',
|
||||
ninth_party: 'Negende partij',
|
||||
tenth_party: 'Tiende partij',
|
||||
eleventh_party: 'Elfde partij',
|
||||
twelfth_party: 'Twaalfde partij',
|
||||
thirteenth_party: 'Dertiende partij',
|
||||
fourteenth_party: 'Veertiende partij',
|
||||
fifteenth_party: 'Vijftiende partij',
|
||||
sixteenth_party: 'Zestiende partij',
|
||||
seventeenth_party: 'Zeventiende partij',
|
||||
eighteenth_party: 'Achttiende partij',
|
||||
nineteenth_party: 'Negentiende partij',
|
||||
twentieth_party: 'Twintigste partij',
|
||||
draw: 'Tekenen',
|
||||
add: 'Toevoegen',
|
||||
or_add_field_without_drawing: 'Of voeg een veld toe zonder te tekenen',
|
||||
text: 'Tekst',
|
||||
number: 'Getal',
|
||||
signature: 'Signatuur',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
image: 'Afbeelding',
|
||||
file: 'Bestand',
|
||||
select: 'Selecteren',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Meerdere',
|
||||
radio: 'Radio',
|
||||
cells: 'Cellen',
|
||||
stamp: 'Stempel',
|
||||
payment: 'Betaling',
|
||||
phone: 'Telefoon',
|
||||
text_field: 'Tekstveld',
|
||||
signature_field: 'Signatuurveld',
|
||||
initials_field: 'Initialenveld',
|
||||
date_field: 'Datumveld',
|
||||
number_field: 'Getalveld',
|
||||
image_field: 'Afbeeldingsveld',
|
||||
file_field: 'Bestandsveld',
|
||||
select_field: 'Selectieveld',
|
||||
checkbox_field: 'Aankruisvakje',
|
||||
multiple_field: 'Meerkeuzeveld',
|
||||
radio_field: 'Radiogroepveld',
|
||||
cells_field: 'Cellenveld',
|
||||
stamp_field: 'Stempelveld',
|
||||
payment_field: 'Betalingsveld',
|
||||
phone_field: 'Telefoonveld',
|
||||
draw_a_text_field_on_the_page_with_a_mouse: 'Teken met de muis een tekstveld op de pagina',
|
||||
drag_and_drop_any_other_field_type_on_the_page: 'Sleep elk ander veldtype naar de pagina',
|
||||
click_on_the_field_type_above_to_start_drawing_it: 'Klik op het bovenstaande veldtype om te beginnen met tekenen',
|
||||
please_draw_fields_to_prepare_the_document: 'Teken velden om het document voor te bereiden.',
|
||||
only_pdf_and_images_are_supported: 'Alleen PDF en afbeeldingen worden ondersteund.',
|
||||
unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification: 'Ontgrendel het SMS-geverifieerde veld voor telefoonnummer met een betaald abonnement. Gebruik het tekstveld voor telefoonnummers zonder verificatie.',
|
||||
available_only_in_pro: 'Alleen beschikbaar in Pro',
|
||||
failed_to_download_files: 'Bestanden downloaden mislukt',
|
||||
please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed: 'Voeg velden toe voor de {submitter_name}. Of verwijder de {submitter_name} als deze niet nodig is.',
|
||||
draw_field: 'Teken het veld {field}',
|
||||
replace: 'Vervangen',
|
||||
uploading_: 'Bezig met uploaden...',
|
||||
add_document: 'Doc. toevoegen',
|
||||
none: 'Geen',
|
||||
ssn: 'SSN',
|
||||
ein: 'EIN',
|
||||
email: 'E-mail',
|
||||
url: 'URL',
|
||||
zip: 'Postcode',
|
||||
custom: 'Aangepast',
|
||||
numbers_only: 'Alleen cijfers',
|
||||
letters_only: 'Alleen letters',
|
||||
regexp_validation: 'Regex validatie',
|
||||
enter_pdf_password: 'Voer PDF-wachtwoord in',
|
||||
wrong_password: 'Onjuist wachtwoord',
|
||||
currency: 'Valuta',
|
||||
save_and_preview: 'Opslaan & bekijken',
|
||||
preferences: 'Voorkeuren',
|
||||
available_in_pro: 'Beschikbaar in Pro',
|
||||
some_fields_are_missing_in_the_formula: 'Sommige velden ontbreken in de formule.',
|
||||
learn_more: 'Meer informatie',
|
||||
and: 'en',
|
||||
or: 'of',
|
||||
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
|
||||
start_tour: 'Rondleiding starten',
|
||||
or_add_from: 'Of toevoegen van',
|
||||
sync: 'Synchroniseren',
|
||||
syncing: 'Synchroniseren...'
|
||||
}
|
||||
|
||||
export { en, es, it, pt, fr, de, nl }
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute text-center w-full bottom-0 pr-3 mb-4">
|
||||
<div class="absolute w-full bottom-0 pr-3 mb-4">
|
||||
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 draw-field-container-mobile">
|
||||
<div class="flex items-center space-x-2">
|
||||
<component
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1 hover:bg-black/10 transition-colors"
|
||||
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1 hover:bg-black/10 transition-colors rounded"
|
||||
@click="$emit('scroll-to', item)"
|
||||
>
|
||||
<div
|
||||
@@ -23,7 +23,7 @@
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
@click.stop="isShowConditionsModal = true"
|
||||
>
|
||||
<IconRouteAltLeft
|
||||
@@ -48,7 +48,7 @@
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('remove', item)"
|
||||
>
|
||||
@@ -63,7 +63,7 @@
|
||||
class="tooltip tooltip-left before:text-xs"
|
||||
>
|
||||
<button
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
|
||||
@click.stop="$emit('reorder', item)"
|
||||
>
|
||||
<IconSortDescending2
|
||||
@@ -75,14 +75,14 @@
|
||||
</span>
|
||||
<template v-if="withArrows">
|
||||
<button
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('up', item)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('down', item)"
|
||||
>
|
||||
@@ -94,12 +94,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pb-2 pt-1.5 document-preview-name">
|
||||
<div class="flex items-center gap-1 pb-2 pt-1.5 document-preview-name">
|
||||
<GoogleDriveDocumentSettings
|
||||
v-if="item.google_drive_file_id"
|
||||
:template-id="template.id"
|
||||
:google-drive-file-id="item.google_drive_file_id"
|
||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||
/>
|
||||
<Contenteditable
|
||||
:model-value="item.name"
|
||||
:icon-width="16"
|
||||
:icon-inline="true"
|
||||
:float-icon="!item.google_drive_file_id"
|
||||
:hide-icon="!item.google_drive_file_id"
|
||||
:editable="editable"
|
||||
style="max-width: 95%"
|
||||
class="mx-auto"
|
||||
@update:model-value="onUpdateName"
|
||||
/>
|
||||
@@ -123,6 +131,7 @@ import Upload from './upload'
|
||||
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import ReplaceButton from './replace'
|
||||
import GoogleDriveDocumentSettings from './google_drive_document_settings'
|
||||
import Field from './field'
|
||||
import FieldType from './field_type'
|
||||
|
||||
@@ -133,6 +142,7 @@ export default {
|
||||
IconRouteAltLeft,
|
||||
ConditionsModal,
|
||||
ReplaceButton,
|
||||
GoogleDriveDocumentSettings,
|
||||
IconSortDescending2
|
||||
},
|
||||
inject: ['t'],
|
||||
|
||||
@@ -3,29 +3,155 @@
|
||||
<label
|
||||
id="add_document_button"
|
||||
:for="inputId"
|
||||
class="btn btn-outline w-full add-document-button"
|
||||
class="btn btn-outline w-full add-document-button px-0"
|
||||
:class="{ 'btn-disabled': isLoading }"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
width="20"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<IconUpload
|
||||
v-else
|
||||
width="20"
|
||||
/>
|
||||
<span v-if="isLoading">
|
||||
{{ t('uploading_') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('add_document') }}
|
||||
</span>
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-full"
|
||||
>
|
||||
<span
|
||||
class="flex items-center space-x-2 w-full justify-center"
|
||||
:class="{ 'pl-3': withGoogleDrive }"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
width="20"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<IconUpload
|
||||
v-else
|
||||
width="20"
|
||||
/>
|
||||
<span v-if="isLoading">
|
||||
{{ t('uploading_') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="mr-1 whitespace-nowrap truncate"
|
||||
>
|
||||
{{ t('add_document') }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="withGoogleDrive"
|
||||
class="dropdown dropdown-end dropdown-top inline h-full"
|
||||
style="width: 33px"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
class="flex items-center h-full cursor-pointer"
|
||||
>
|
||||
<IconChevronDown class="w-5 h-5 flex-shrink-0" />
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
:style="{ backgroundColor }"
|
||||
class="dropdown-content p-2 mt-2 shadow menu text-base mb-1 rounded-box text-right !text-base-content"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@click="openGoogleDriveModal"
|
||||
>
|
||||
<IconBrandGoogleDrive class="w-5 h-5 flex-shrink-0" />
|
||||
<span class="whitespace-nowrap text-sm normal-case font-medium">Google Drive</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<Teleport
|
||||
v-if="showGoogleDriveModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="showGoogleDriveModal = false"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
Google Drive
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl modal-close-button"
|
||||
@click.prevent="showGoogleDriveModal = false"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
v-if="showGoogleDriveOauthButton"
|
||||
method="post"
|
||||
:action="googleDriveOauthPath"
|
||||
@submit="isConnectGoogleDriveClicked = true"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
id="gdrive_oauth_button"
|
||||
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
|
||||
data-turbo="false"
|
||||
type="submit"
|
||||
:disabled="isConnectGoogleDriveClicked"
|
||||
>
|
||||
<span v-if="isConnectGoogleDriveClicked">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
<span class="">Submitting...</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconBrandGoogleDrive />
|
||||
<span>Connect Google Drive</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-else
|
||||
class="relative"
|
||||
>
|
||||
<iframe
|
||||
class="border border-base-300 rounded-lg"
|
||||
style="width: 100%; height: 440px; background: white;"
|
||||
src="/template_google_drive"
|
||||
/>
|
||||
<div v-if="isLoadingGoogleDrive">
|
||||
<div
|
||||
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
|
||||
style="margin: 1px"
|
||||
/>
|
||||
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<form
|
||||
ref="form"
|
||||
class="hidden"
|
||||
>
|
||||
<input
|
||||
v-for="file in googleDriveFiles"
|
||||
:key="file.id"
|
||||
name="google_drive_file_ids[]"
|
||||
:value="file.id"
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="input"
|
||||
@@ -40,20 +166,32 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconUpload, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'DocumentsUpload',
|
||||
components: {
|
||||
IconUpload,
|
||||
IconInnerShadowTop
|
||||
IconInnerShadowTop,
|
||||
IconChevronDown,
|
||||
IconBrandGoogleDrive
|
||||
},
|
||||
inject: ['baseFetch', 't'],
|
||||
inject: ['baseFetch', 't', 'backgroundColor'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -63,22 +201,85 @@ export default {
|
||||
emits: ['success', 'error'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
isLoading: false,
|
||||
isConnectGoogleDriveClicked: false,
|
||||
isLoadingGoogleDrive: false,
|
||||
googleDriveFiles: [],
|
||||
showGoogleDriveModal: false,
|
||||
showGoogleDriveOauthButton: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return 'el' + Math.random().toString(32).split('.')[1]
|
||||
},
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
googleDriveOauthPath () {
|
||||
const params = {
|
||||
access_type: 'offline',
|
||||
include_granted_scopes: 'true',
|
||||
prompt: 'consent',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
].join(' '),
|
||||
state: new URLSearchParams({
|
||||
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
|
||||
}).toString()
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(params).toString()
|
||||
|
||||
return `/auth/google_oauth2?${query}`
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
|
||||
if (this.queryParams.get('google_drive_open') === '1') {
|
||||
this.openGoogleDriveModal()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
},
|
||||
methods: {
|
||||
async upload () {
|
||||
openGoogleDriveModal () {
|
||||
this.showGoogleDriveModal = true
|
||||
this.isLoadingGoogleDrive = true
|
||||
},
|
||||
messageHandler (event) {
|
||||
if (event.data.type === 'google-drive-files-picked') {
|
||||
this.googleDriveFiles = event.data.files || []
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.isLoadingGoogleDrive = true
|
||||
|
||||
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
|
||||
if (resp.ok) {
|
||||
this.showGoogleDriveModal = false
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoadingGoogleDrive = false
|
||||
})
|
||||
})
|
||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||
this.isLoadingGoogleDrive = false
|
||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||
this.showGoogleDriveOauthButton = true
|
||||
}
|
||||
},
|
||||
async upload ({ path } = {}) {
|
||||
this.isLoading = true
|
||||
|
||||
this.baseFetch(this.uploadUrl, {
|
||||
return this.baseFetch(path || `/templates/${this.templateId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: new FormData(this.$refs.form)
|
||||
@@ -111,6 +312,10 @@ export default {
|
||||
this.isLoading = false
|
||||
}
|
||||
})
|
||||
} else if (data.status === 'google_drive_file_missing') {
|
||||
alert(data.error)
|
||||
this.$emit('error', data.error)
|
||||
this.isLoading = false
|
||||
} else {
|
||||
this.$emit('error', data.error)
|
||||
this.isLoading = false
|
||||
@@ -122,6 +327,8 @@ export default {
|
||||
this.isLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}).catch(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ class GeneratePreviewImagesJob
|
||||
max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1,
|
||||
Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min
|
||||
|
||||
Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, (1..max_page),
|
||||
Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, 1..max_page,
|
||||
concurrency: 1)
|
||||
end
|
||||
end
|
||||
@@ -47,6 +47,7 @@ class ProcessSubmitterCompletionJob
|
||||
completed_submitter.assign_attributes(
|
||||
submission_id: submitter.submission_id,
|
||||
account_id: submission.account_id,
|
||||
is_first: !CompletedSubmitter.exists?(submission: submitter.submission_id, is_first: true),
|
||||
template_id: submission.template_id,
|
||||
source: submission.source,
|
||||
sms_count: sms_events.sum { |e| e.data['segments'] || 1 },
|
||||
@@ -93,18 +94,26 @@ class ProcessSubmitterCompletionJob
|
||||
|
||||
def enqueue_completed_emails(submitter)
|
||||
submission = submitter.submission
|
||||
template = submitter.template
|
||||
|
||||
user = submission.created_by_user || submitter.template.author
|
||||
user = submission.created_by_user || template.author
|
||||
|
||||
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
|
||||
submitter.template&.preferences&.dig('completed_notification_email_enabled') != false
|
||||
if submission.submitters.map(&:email).exclude?(user.email) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
|
||||
user.role != 'integration'
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
end
|
||||
(!template || template.preferences['completed_notification_email_enabled'] != false)
|
||||
user_submitter = submission.submitters.find { |s| s.email == user.email }
|
||||
|
||||
is_sent_to_user =
|
||||
if user.role != 'integration' &&
|
||||
(!user_submitter || user_submitter.preferences['send_email'] == false) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
build_bcc_addresses(submission).each do |to|
|
||||
next if is_sent_to_user && to == user.email
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,8 @@ class SendSubmitterInvitationEmailJob
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
return if submitter.completed_at?
|
||||
return if submitter.submission.archived_at?
|
||||
return if submitter.template&.archived_at?
|
||||
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
||||
|
||||
unless Accounts.can_send_invitation_emails?(submitter.account)
|
||||
|
||||
@@ -60,6 +60,10 @@ class Account < ApplicationRecord
|
||||
linked_account_account&.testing?
|
||||
end
|
||||
|
||||
def tz_info
|
||||
@tz_info ||= TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone)
|
||||
end
|
||||
|
||||
def default_template_folder
|
||||
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||
author_id: users.minimum(:id)).tap(&:save!)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
BCC_EMAILS = 'bcc_emails'
|
||||
@@ -59,6 +60,12 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:template_name_has_been_completed_by_submitters),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# completed_at :datetime not null
|
||||
# is_first :boolean
|
||||
# sms_count :integer not null
|
||||
# source :string not null
|
||||
# verification_method :string
|
||||
@@ -18,8 +19,10 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_completed_submitters_on_account_id (account_id)
|
||||
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
|
||||
# index_completed_submitters_account_id_completed_at_is_first (account_id,completed_at) WHERE (is_first = true)
|
||||
# index_completed_submitters_on_account_id_and_completed_at (account_id,completed_at)
|
||||
# index_completed_submitters_on_submission_id (submission_id) UNIQUE WHERE (is_first = true)
|
||||
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
|
||||
#
|
||||
class CompletedSubmitter < ApplicationRecord
|
||||
belongs_to :submitter
|
||||
|
||||
@@ -10,17 +10,21 @@
|
||||
# event_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# submission_id :bigint not null
|
||||
# submitter_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_submission_events_on_created_at (created_at)
|
||||
# index_submission_events_on_submission_id (submission_id)
|
||||
# index_submission_events_on_submitter_id (submitter_id)
|
||||
# index_submission_events_on_account_id (account_id)
|
||||
# index_submission_events_on_created_at (created_at)
|
||||
# index_submission_events_on_submission_id (submission_id)
|
||||
# index_submission_events_on_submitter_id (submitter_id)
|
||||
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (submission_id => submissions.id)
|
||||
# fk_rails_... (submitter_id => submitters.id)
|
||||
#
|
||||
@@ -35,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
serialize :data, coder: JSON
|
||||
|
||||
before_validation :set_submission_id, on: :create
|
||||
before_validation :set_account_id, on: :create
|
||||
|
||||
enum :event_type, {
|
||||
send_email: 'send_email',
|
||||
@@ -63,4 +68,8 @@ class SubmissionEvent < ApplicationRecord
|
||||
def set_submission_id
|
||||
self.submission_id = submitter&.submission_id
|
||||
end
|
||||
|
||||
def set_account_id
|
||||
self.account_id = submitter&.account_id
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -50,7 +50,7 @@ class User < ApplicationRecord
|
||||
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
|
||||
|
||||
FULL_EMAIL_REGEXP =
|
||||
/\A[a-z0-9][\.']?(?:(?:[a-z0-9_-]+[\.\+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[\.-])*[a-z0-9]+\.[a-z]{2,}\z/i
|
||||
/\A[a-z0-9][.']?(?:(?:[a-z0-9_-]+[.+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[.-])*[a-z0-9]+\.[a-z]{2,}\z/i
|
||||
|
||||
has_one_attached :signature
|
||||
has_one_attached :initials
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<%= hidden_field_tag :redir, params[:redir] %>
|
||||
<% end %>
|
||||
<submit-form data-on="change">
|
||||
<%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR it-IT es-ES]).map { |code| [t("language_#{code}"), code] }, I18n.locale), class: 'select select-sm border-base-content/30 text-base' %>
|
||||
<%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR it-IT es-ES nl-NL]).map { |code| [t("language_#{code}"), code] }, I18n.locale), class: 'select select-sm border-base-content/30 text-base' %>
|
||||
</submit-form>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-google-drive"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 10l-6 10l-3 -5l6 -10z" /><path d="M9 15h12l-3 5h-12" /><path d="M15 15l-6 -10h6l6 10z" /></svg>
|
||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1,8 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10z" />
|
||||
<path d="M7 20h10" /><path d="M9 16v4" />
|
||||
<path d="M15 16v4" /></svg>
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,6 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14z" />
|
||||
<path d="M11 4h2" />
|
||||
<path d="M12 17v.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -0,0 +1,5 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16z" />
|
||||
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -19,6 +19,12 @@
|
||||
<%= stylesheet_pack_tag 'application', media: 'all' %>
|
||||
</head>
|
||||
<body>
|
||||
<% if params[:modal].present? %>
|
||||
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %>
|
||||
<% if url_params[:action] == 'new' %>
|
||||
<open-modal src="<%= params[:modal] %>"></open-modal>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<turbo-frame id="modal"></turbo-frame>
|
||||
<turbo-frame id="drawer"></turbo-frame>
|
||||
<%= render 'shared/navbar' %>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<span class="disabled">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
|
||||
<span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
|
||||
<% if disabled_with %>
|
||||
<span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</span>
|
||||
@@ -66,7 +66,7 @@
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %>
|
||||
<%= t('plans') %>
|
||||
<span class="badge badge-warning"><%= t('pro') %></span>
|
||||
<% end %>
|
||||
@@ -74,13 +74,13 @@
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
|
||||
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<li>
|
||||
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
|
||||
<%= t('embedding') %>
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
@@ -28,9 +28,16 @@
|
||||
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content <%= bg_class %>">
|
||||
<%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
|
||||
</span>
|
||||
<p class="text-sm leading-none text-base-content/60 pt-1.5">
|
||||
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</p>
|
||||
<div class="flex items-center space-x-1 pt-1.5">
|
||||
<span class="text-sm leading-none text-base-content/60">
|
||||
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</span>
|
||||
<% if (device = DetectBrowserDevice.call(event.data['ua'])) %>
|
||||
<span class="tooltip tooltip-top" data-tip="<%= t(device) %>">
|
||||
<%= svg_icon("device_#{device}", class: 'w-4 h-4') %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-base-content/80 mt-1">
|
||||
<% if event.event_type == 'complete_verification' %>
|
||||
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
|
||||
|
||||
@@ -1,115 +1 @@
|
||||
<% submitter_preferences_index = template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } %>
|
||||
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
|
||||
<div class="form-control">
|
||||
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
|
||||
<div class="flex justify-between items-center">
|
||||
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
|
||||
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails || local_assigns[:disable_email], checked: can_send_emails && !local_assigns.key?(:resend_email) && !local_assigns[:disable_email] && template&.preferences&.dig('request_email_enabled') != false %>
|
||||
<span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
|
||||
<% end %>
|
||||
<div>
|
||||
<% if can_send_emails %>
|
||||
<%= render 'submissions/email_stats' %>
|
||||
<%= content_for(:edit_button) || capture do %>
|
||||
<toggle-visible data-element-ids="<%= %w[message_field].to_json %>" class="flex">
|
||||
<label>
|
||||
<%= f.check_box :is_custom_message, checked: false, class: 'hidden peer', data: { action: 'change:toggle-visible#trigger', type: 'checkbox' } %>
|
||||
<span class="link peer-checked:hidden"><%= t('edit_message') %></span>
|
||||
</label>
|
||||
</toggle-visible>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? && !can_send_emails %>
|
||||
<div class="alert my-4">
|
||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
||||
<div>
|
||||
<p class="font-bold"><%= t('smtp_not_configured') %></p>
|
||||
<p class="text-gray-700">
|
||||
<%= t('configure_smtp_settings_in_order_to_send_emails_') %>
|
||||
<br>
|
||||
<a class="link font-medium" data-turbo-frame="_top" href="<%= settings_email_index_path %>">
|
||||
<%= t('go_to_smtp_settings') %>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
|
||||
<div id="message_field" class="card card-compact bg-base-300/40 hidden">
|
||||
<div class="card-body">
|
||||
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden form-control space-y-2">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= f.label :subject, t('subject'), class: 'label' %>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<label for="<%= toggle_uuid %>" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= f.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
|
||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'submissions/message_fields' %>
|
||||
</div>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<div class="hidden peer-checked:block form-control space-y-2">
|
||||
<% uuid = SecureRandom.uuid %>
|
||||
<% options = template_submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<% template_submitters.each_with_index do |submitter, index| %>
|
||||
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
|
||||
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
</div>
|
||||
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render partial: 'submissions/send_email_base', locals: local_assigns %>
|
||||
@@ -0,0 +1,116 @@
|
||||
<% submitter_preferences_index = template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } %>
|
||||
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
|
||||
<% message_field_id = "message_field_#{SecureRandom.hex(3)}" %>
|
||||
<div class="form-control">
|
||||
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
|
||||
<div class="flex justify-between items-center">
|
||||
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
|
||||
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails || local_assigns[:disable_email], checked: can_send_emails && !local_assigns.key?(:resend_email) && !local_assigns[:disable_email] && template&.preferences&.dig('request_email_enabled') != false %>
|
||||
<span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
|
||||
<% end %>
|
||||
<div>
|
||||
<% if can_send_emails %>
|
||||
<%= render 'submissions/email_stats' %>
|
||||
<%= content_for(:edit_button) || capture do %>
|
||||
<toggle-visible data-element-ids="<%= [message_field_id].to_json %>" class="flex">
|
||||
<label>
|
||||
<%= f.check_box :is_custom_message, checked: false, class: 'hidden peer', data: { action: 'change:toggle-visible#trigger', type: 'checkbox' } %>
|
||||
<span class="link peer-checked:hidden"><%= t('edit_message') %></span>
|
||||
</label>
|
||||
</toggle-visible>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? && !can_send_emails %>
|
||||
<div class="alert my-4">
|
||||
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
|
||||
<div>
|
||||
<p class="font-bold"><%= t('smtp_not_configured') %></p>
|
||||
<p class="text-gray-700">
|
||||
<%= t('configure_smtp_settings_in_order_to_send_emails_') %>
|
||||
<br>
|
||||
<a class="link font-medium" data-turbo-frame="_top" href="<%= settings_email_index_path %>">
|
||||
<%= t('go_to_smtp_settings') %>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
|
||||
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
|
||||
<div class="card-body">
|
||||
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden form-control space-y-2">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= f.label :subject, t('subject'), class: 'label' %>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<label for="<%= toggle_uuid %>" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= f.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
|
||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'submissions/message_fields' %>
|
||||
</div>
|
||||
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
|
||||
<div class="hidden peer-checked:block form-control space-y-2">
|
||||
<% uuid = SecureRandom.uuid %>
|
||||
<% options = template_submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<% template_submitters.each_with_index do |submitter, index| %>
|
||||
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
|
||||
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
</div>
|
||||
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :message, t('body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +1,11 @@
|
||||
<% align = field.dig('preferences', 'align') %>
|
||||
<% valign = field.dig('preferences', 'valign') %>
|
||||
<% color = field.dig('preferences', 'color') %>
|
||||
<% bg_color = field.dig('preferences', 'background') %>
|
||||
<% font = field.dig('preferences', 'font') %>
|
||||
<% font_type = field.dig('preferences', 'font_type') %>
|
||||
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
|
||||
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
|
||||
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
|
||||
<% if field['type'] == 'signature' %>
|
||||
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
|
||||
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
|
||||
<% if document.preview_images.first %>
|
||||
<scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer">
|
||||
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
|
||||
<img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
|
||||
<div class="pb-2 pt-1.5 text-center" dir="auto">
|
||||
<%= item['name'].presence || document.filename.base %>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@
|
||||
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
|
||||
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
|
||||
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= width %>" class="rounded" height="<%= height %>">
|
||||
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
|
||||
<div class="top-0 bottom-0 left-0 right-0 absolute">
|
||||
<% document_annots_index[index]&.each do |annot| %>
|
||||
<%= render 'submissions/annotation', annot: %>
|
||||
|
||||
@@ -38,14 +38,6 @@
|
||||
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
|
||||
</toggle-submit>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div class="divider uppercase"><%= t('or') %></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div>
|
||||
<%= link_to t('create_free_account'), registration_path, class: 'white-button w-full' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<%= svg_icon('cloud_upload', class: 'w-9 h-9') %>
|
||||
</span>
|
||||
<div class="font-medium mb-1">
|
||||
<%= t('upload_new_document') %>
|
||||
<%= t('upload_a_new_document') %>
|
||||
</div>
|
||||
</span>
|
||||
<span class="flex flex-col items-center hidden" data-target="dashboard-dropzone.fileDropzoneLoading">
|
||||
|
||||
@@ -5,16 +5,23 @@
|
||||
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border-2 border-base-300 border-dashed" data-target="file-dropzone.area">
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center p-2 pointer-events-none">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
|
||||
<span data-target="file-dropzone.icon" class="flex flex-col items-center <%= 'mb-5' if Docuseal.multitenant? %>">
|
||||
<span>
|
||||
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
|
||||
</span>
|
||||
<div class="font-medium mb-1">
|
||||
<%= t('upload_new_document') %>
|
||||
<%= t('upload_a_new_document') %>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<div class="text-sm">
|
||||
<%= t('click_to_upload_or_drag_and_drop_html') %>
|
||||
</div>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<a class="flex absolute bottom-4 items-center text-sm mt-2 pointer-events-auto" href="<%= new_template_path(modal_tab: 'google_drive') %>" data-turbo-frame="modal">
|
||||
<span><%= t('or_add_from') %></span>
|
||||
<%= svg_icon('brand_gdrive', class: 'w-4 h-4 ml-1') %>
|
||||
<span class="ml-1 font-medium hover:underline">Google Drive</span>
|
||||
</a>
|
||||
<% end %>
|
||||
</span>
|
||||
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
|
||||
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
|
||||
<% if @base_template %>
|
||||
<%= hidden_field_tag :base_template_id, @base_template.id %>
|
||||
<% end %>
|
||||
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.linked_accounts.active.accessible_by(current_ability).exists? %>
|
||||
<div class="form-control -mb-2 mt-2">
|
||||
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.active.accessible_by(current_ability)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control mt-4">
|
||||
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="mt-3 mb-4 flex items-center justify-between">
|
||||
<label for="folder_name" class="cursor-pointer">
|
||||
<%= svg_icon('folder', class: 'w-6 h-6') %>
|
||||
</label>
|
||||
<folder-autocomplete class="flex justify-between w-full">
|
||||
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
|
||||
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
|
||||
</set-value>
|
||||
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
|
||||
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
|
||||
<%= t('change_folder') %>
|
||||
</label>
|
||||
</set-value>
|
||||
</folder-autocomplete>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.button button_title(title: @base_template ? t('submit') : t('create'), disabled_with: t('creating')), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -103,12 +103,12 @@
|
||||
</div>
|
||||
<% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %>
|
||||
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
|
||||
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
|
||||
<%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if local_assigns[:archived] && can?(:destroy, submission) %>
|
||||
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
|
||||
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
|
||||
<%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('trash', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -210,12 +210,12 @@
|
||||
</div>
|
||||
<% if !submission.archived_at? && !template&.archived_at? %>
|
||||
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
|
||||
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
|
||||
<%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if local_assigns[:archived] && can?(:destroy, submission) %>
|
||||
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
|
||||
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
|
||||
<%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('trash', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
|
||||
@@ -1,33 +1,3 @@
|
||||
<%= render 'shared/turbo_modal', title: @base_template ? t('clone_template') : t('new_document_template') do %>
|
||||
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
|
||||
<% if @base_template %>
|
||||
<%= hidden_field_tag :base_template_id, @base_template.id %>
|
||||
<% end %>
|
||||
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.linked_accounts.active.accessible_by(current_ability).exists? %>
|
||||
<div class="form-control -mb-2 mt-2">
|
||||
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.active.accessible_by(current_ability)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control mt-6">
|
||||
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="mt-3 mb-4 flex items-center justify-between">
|
||||
<label for="folder_name" class="cursor-pointer">
|
||||
<%= svg_icon('folder', class: 'w-6 h-6') %>
|
||||
</label>
|
||||
<folder-autocomplete class="flex justify-between w-full">
|
||||
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
|
||||
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
|
||||
</set-value>
|
||||
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
|
||||
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
|
||||
<%= t('change_folder') %>
|
||||
</label>
|
||||
</set-value>
|
||||
</folder-autocomplete>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.button button_title(title: @base_template ? t('submit') : t('create'), disabled_with: t('creating')), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates/file_form' %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,70 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY %>_form">
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY).value %>
|
||||
<% template_email_preferences_values = @template.preferences.values_at('completed_notification_email_subject', 'completed_notification_email_body').compact_blank.presence %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% if is_custom_template_email %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_completed_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_completed_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert3"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_subject, :completed_notification_email_body).new(@template.preferences['completed_notification_email_subject'].presence || configs['subject'], @template.preferences['completed_notification_email_body'].presence || configs['body']) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :completed_notification_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_completed_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :completed_notification_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :completed_notification_email_body, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert3"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_enabled, :completed_notification_email_attach_audit, :completed_notification_email_attach_documents).new(@template.preferences['completed_notification_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['completed_notification_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['completed_notification_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_documents, { checked: ff.object.completed_notification_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_audit, { checked: ff.object.completed_notification_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_enabled, { checked: ff.object.completed_notification_email_enabled != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_completed_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert3" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY %>_form">
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY).value %>
|
||||
<% template_email_preferences_values = @template.preferences.values_at('documents_copy_email_subject', 'documents_copy_email_body').compact_blank.presence %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% if is_custom_template_email %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_documents_copy_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_documents_copy_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert2"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_reply_to, :documents_copy_email_subject, :documents_copy_email_body).new(@template.preferences['documents_copy_email_reply_to'].presence || configs['reply_to'], @template.preferences['documents_copy_email_subject'].presence || configs['subject'], @template.preferences['documents_copy_email_body'].presence || configs['body']) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :documents_copy_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_documents_copy_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :documents_copy_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :documents_copy_email_body, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% if can?(:manage, :reply_to) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_reply_to, t('reply_to'), class: 'label' %>
|
||||
<%= ff.email_field :documents_copy_email_reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_documents_copy_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_enabled, :documents_copy_email_attach_audit, :documents_copy_email_attach_documents).new(@template.preferences['documents_copy_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['documents_copy_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['documents_copy_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_documents, { checked: ff.object.documents_copy_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_audit, { checked: ff.object.documents_copy_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false && configs['enabled'] != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_documents_copy_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert2" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,110 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY %>_form">
|
||||
<% template_email_preferences_values = @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank %>
|
||||
<% default_template_email_preferences_values = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body') %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% multiple_submitters = @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<% if is_custom_template_email || @template.preferences['submitters'].to_a.any? %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_invitation_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_invitation_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
|
||||
<%= tag.input id: 'request_email_per_submitter', value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: @template.preferences['submitters'].to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden">
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(template_email_preferences_values.presence || default_template_email_preferences_values)) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_invitation_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% elsif multiple_submitters %>
|
||||
<label for="request_email_per_submitter" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :request_email_body, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if multiple_submitters %>
|
||||
<div class="hidden peer-checked:block">
|
||||
<% options = @template.submitters.map { |e| [e['name'], "request_email_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap mb-2">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<%= f.fields_for :preferences do |ff| %>
|
||||
<% @template.submitters.each_with_index do |submitter, index| %>
|
||||
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
|
||||
<% submitter_email_preferences_values = submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence %>
|
||||
<%= ff.fields_for :submitters, Struct.new(:request_email_subject, :request_email_body).new(*(submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || template_email_preferences_values.presence || default_template_email_preferences_values)), index: nil do |fff| %>
|
||||
<%= fff.hidden_field :uuid, value: submitter['uuid'] %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= fff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if submitter_email_preferences_values.present? %>
|
||||
<label for="submitter_invitation_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= fff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= fff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= fff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_enabled).new(@template.preferences['request_email_enabled']) do |ff| %>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_signature_request_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :request_email_enabled, { checked: ff.object.request_email_enabled != false, class: 'toggle' }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_invitation_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert1" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Whitespace-only changes.
@@ -98,157 +98,17 @@
|
||||
<%= t('signature_request_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
|
||||
<%= tag.input id: 'request_email_per_submitter', value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: @template.preferences['submitters'].to_a.size > 1 %>
|
||||
<div class="peer-checked:hidden">
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(@template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<label for="request_email_per_submitter" class="label underline">
|
||||
<%= t('edit_per_party') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<div class="hidden peer-checked:block">
|
||||
<% options = @template.submitters.map { |e| [e['name'], "request_email_#{e['uuid']}"] } %>
|
||||
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
|
||||
<ul class="tabs w-full flex flex-nowrap mb-2">
|
||||
<% options.each_with_index do |(label, val), index| %>
|
||||
<div class="w-full">
|
||||
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
|
||||
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<%= f.fields_for :preferences do |ff| %>
|
||||
<% @template.submitters.each_with_index do |submitter, index| %>
|
||||
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
|
||||
<%= ff.fields_for :submitters, Struct.new(:request_email_subject, :request_email_body).new(*(submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))), index: nil do |fff| %>
|
||||
<%= fff.hidden_field :uuid, value: submitter['uuid'] %>
|
||||
<div class="form-control">
|
||||
<%= fff.label :request_email_subject, t('email_subject'), class: 'label' %>
|
||||
<%= fff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= fff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= fff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.fields_for :preferences, Struct.new(:request_email_enabled).new(@template.preferences['request_email_enabled']) do |ff| %>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= 'Send signature request email' %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :request_email_enabled, { checked: ff.object.request_email_enabled != false, class: 'toggle' }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert1" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_invitation_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'templates_preferences/submitter_invitation_reminder_email_collapse' %>
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
<input type="checkbox" name="accordion">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
<%= t('documents_copy_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert2"></toggle-on-submit>
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY).value %>
|
||||
<%= f.fields_for :preferences, Struct.new(:documents_copy_email_reply_to, :documents_copy_email_subject, :documents_copy_email_body, :documents_copy_email_enabled, :documents_copy_email_attach_audit, :documents_copy_email_attach_documents).new(@template.preferences['documents_copy_email_reply_to'].presence || configs['reply_to'], @template.preferences['documents_copy_email_subject'].presence || configs['subject'], @template.preferences['documents_copy_email_body'].presence || configs['body'], @template.preferences['documents_copy_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['documents_copy_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['documents_copy_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_subject, t('email_subject'), class: 'label' %>
|
||||
<%= ff.text_field :documents_copy_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :documents_copy_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<% if can?(:manage, :reply_to) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_reply_to, t('reply_to'), class: 'label' %>
|
||||
<%= ff.email_field :documents_copy_email_reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_documents, { checked: ff.object.documents_copy_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_attach_audit, { checked: ff.object.documents_copy_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false && configs['enabled'] != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert2" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_documents_copy_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
@@ -257,57 +117,7 @@
|
||||
<%= t('completed_notification_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert3"></toggle-on-submit>
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY).value %>
|
||||
<%= f.fields_for :preferences, Struct.new(:completed_notification_email_subject, :completed_notification_email_body, :completed_notification_email_enabled, :completed_notification_email_attach_audit, :completed_notification_email_attach_documents).new(@template.preferences['completed_notification_email_subject'].presence || configs['subject'], @template.preferences['completed_notification_email_body'].presence || configs['body'], @template.preferences['completed_notification_email_enabled'], configs['attach_audit_log'] != false && @template.preferences['completed_notification_email_attach_audit'] != false, configs['attach_documents'] != false && @template.preferences['completed_notification_email_attach_documents'] != false) do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :completed_notification_email_subject, t('email_subject'), class: 'label' %>
|
||||
<%= ff.text_field :completed_notification_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
|
||||
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :completed_notification_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_documents_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_documents, { checked: ff.object.completed_notification_email_attach_documents != false, class: 'toggle', disabled: configs['attach_documents'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('attach_audit_log_pdf_to_the_email') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_attach_audit, { checked: ff.object.completed_notification_email_attach_audit != false, class: 'toggle', disabled: configs['attach_audit_log'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2.5 px-1 mb-2">
|
||||
<span>
|
||||
<%= t('send_emails_automatically_on_completion') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= ff.check_box :completed_notification_email_enabled, { checked: ff.object.completed_notification_email_enabled != false, class: 'toggle', disabled: configs['enabled'] == false }, 'true', 'false' %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert3" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_completed_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,9 +169,9 @@
|
||||
<span>
|
||||
<%= t('share_template_with_test_mode') %>
|
||||
</span>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: current_account.testing_accounts) %>
|
||||
</submit-form>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: current_account.testing_accounts) %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -25,8 +25,8 @@ module DocuSeal
|
||||
|
||||
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
|
||||
|
||||
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT
|
||||
es it de fr pl uk cs pt he nl ar ko ja]
|
||||
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT nl-NL
|
||||
es it de fr nl pl uk cs pt he ar ko ja]
|
||||
config.i18n.fallbacks = [:en]
|
||||
|
||||
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
|
||||
|
||||
+1859
-815
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -98,8 +98,9 @@ Rails.application.routes.draw do
|
||||
resources :submissions_filters, only: %i[show], param: 'name'
|
||||
resources :templates, only: %i[new create edit update show destroy] do
|
||||
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
|
||||
resources :documents, only: %i[create], controller: 'template_documents'
|
||||
resources :documents, only: %i[index create], controller: 'template_documents'
|
||||
resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace'
|
||||
resources :detect_fields, only: %i[create], controller: 'templates_detect_fields' unless Docuseal.multitenant?
|
||||
resources :restore, only: %i[create], controller: 'templates_restore'
|
||||
resources :archived, only: %i[index], controller: 'templates_archived_submissions'
|
||||
resources :submissions, only: %i[new create]
|
||||
@@ -107,7 +108,7 @@ Rails.application.routes.draw do
|
||||
resource :preview, only: %i[show], controller: 'templates_preview'
|
||||
resource :form, only: %i[show], controller: 'templates_form_preview'
|
||||
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
||||
resource :preferences, only: %i[show create], controller: 'templates_preferences'
|
||||
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
|
||||
resource :share_link, only: %i[show create], controller: 'templates_share_link'
|
||||
resources :recipients, only: %i[create], controller: 'templates_recipients'
|
||||
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddAccountIdToSubmissionEvents < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_reference :submission_events, :account, null: true, foreign_key: true, index: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddIsFirstToCompletedSubmitters < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# rubocop:disable Rails/ThreeStateBooleanColumn
|
||||
add_column :completed_submitters, :is_first, :boolean
|
||||
# rubocop:enable Rails/ThreeStateBooleanColumn
|
||||
|
||||
add_index :completed_submitters, %i[account_id completed_at],
|
||||
where: 'is_first = TRUE',
|
||||
name: 'index_completed_submitters_account_id_completed_at_is_first'
|
||||
add_index :completed_submitters, :submission_id, unique: true, where: 'is_first = TRUE'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddCompletedAtIndexToCompletedSubmitters < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :completed_submitters, %i[account_id completed_at],
|
||||
name: 'index_completed_submitters_on_account_id_and_completed_at'
|
||||
remove_index :completed_submitters, :account_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddSendSmsSend2faSmsIndex < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_index :submission_events, %i[account_id created_at],
|
||||
where: "event_type IN ('send_sms', 'send_2fa_sms')",
|
||||
name: 'index_submissions_events_on_sms_event_types'
|
||||
end
|
||||
end
|
||||
+9
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_11_21_113910) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "plpgsql"
|
||||
@@ -118,7 +118,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "verification_method"
|
||||
t.index ["account_id"], name: "index_completed_submitters_on_account_id"
|
||||
t.boolean "is_first"
|
||||
t.index ["account_id", "completed_at"], name: "index_completed_submitters_account_id_completed_at_is_first", where: "(is_first = true)"
|
||||
t.index ["account_id", "completed_at"], name: "index_completed_submitters_on_account_id_and_completed_at"
|
||||
t.index ["submission_id"], name: "index_completed_submitters_on_submission_id", unique: true, where: "(is_first = true)"
|
||||
t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true
|
||||
end
|
||||
|
||||
@@ -293,6 +296,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
|
||||
t.datetime "event_timestamp", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "account_id"
|
||||
t.index ["account_id", "created_at"], name: "index_submissions_events_on_sms_event_types", where: "((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))"
|
||||
t.index ["account_id"], name: "index_submission_events_on_account_id"
|
||||
t.index ["created_at"], name: "index_submission_events_on_created_at"
|
||||
t.index ["submission_id"], name: "index_submission_events_on_submission_id"
|
||||
t.index ["submitter_id"], name: "index_submission_events_on_submitter_id"
|
||||
@@ -506,6 +512,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
|
||||
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
|
||||
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
|
||||
add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
|
||||
add_foreign_key "submission_events", "accounts"
|
||||
add_foreign_key "submission_events", "submissions"
|
||||
add_foreign_key "submission_events", "submitters"
|
||||
add_foreign_key "submissions", "templates"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DetectBrowserDevice
|
||||
module_function
|
||||
|
||||
MOBILE_USER_AGENT_REGEXP = /
|
||||
iPhone |
|
||||
iPod |
|
||||
Android.*Mobile|
|
||||
Opera\ Mini |
|
||||
Opera\ Mobi |
|
||||
webOS |
|
||||
IEMobile |
|
||||
Windows\ Phone |
|
||||
BlackBerry |
|
||||
BB10 |
|
||||
Mobile
|
||||
/ix
|
||||
|
||||
TABLET_USER_AGENT_REGEXP = /
|
||||
iPad |
|
||||
Android(?!.*Mobile)|
|
||||
Tablet |
|
||||
Kindle |
|
||||
PlayBook |
|
||||
Silk
|
||||
/ix
|
||||
|
||||
def call(user_agent)
|
||||
return if user_agent.blank?
|
||||
|
||||
return 'mobile' if MOBILE_USER_AGENT_REGEXP.match?(user_agent)
|
||||
return 'tablet' if TABLET_USER_AGENT_REGEXP.match?(user_agent)
|
||||
|
||||
'desktop'
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -78,7 +78,7 @@ module Docuseal
|
||||
|
||||
@fulltext_search =
|
||||
if SearchEntry.table_exists?
|
||||
Docuseal.multitenant? ? true : AccountConfig.exists?(key: :fulltext_search, value: true)
|
||||
Docuseal.multitenant? || AccountConfig.exists?(key: :fulltext_search, value: true)
|
||||
else
|
||||
false
|
||||
end
|
||||
|
||||
+160
@@ -25,6 +25,8 @@ class Pdfium
|
||||
typedef :pointer, :FPDF_BITMAP
|
||||
typedef :pointer, :FPDF_FORMHANDLE
|
||||
typedef :pointer, :FPDF_TEXTPAGE
|
||||
typedef :pointer, :FPDF_PAGEOBJECT
|
||||
typedef :pointer, :FPDF_PATHSEGMENT
|
||||
|
||||
MAX_SIZE = 32_767
|
||||
|
||||
@@ -37,6 +39,9 @@ class Pdfium
|
||||
FPDF_RENDER_FORCEHALFTONE = 0x400
|
||||
FPDF_PRINTING = 0x800
|
||||
|
||||
TextNode = Struct.new(:content, :x, :y, :w, :h, keyword_init: true)
|
||||
LineNode = Struct.new(:x, :y, :w, :h, :tilt, keyword_init: true)
|
||||
|
||||
# rubocop:disable Naming/ClassAndModuleCamelCase
|
||||
class FPDF_LIBRARY_CONFIG < FFI::Struct
|
||||
layout :version, :int,
|
||||
@@ -77,6 +82,37 @@ class Pdfium
|
||||
attach_function :FPDFText_ClosePage, [:FPDF_TEXTPAGE], :void
|
||||
attach_function :FPDFText_CountChars, [:FPDF_TEXTPAGE], :int
|
||||
attach_function :FPDFText_GetText, %i[FPDF_TEXTPAGE int int pointer], :int
|
||||
attach_function :FPDFText_GetUnicode, %i[FPDF_TEXTPAGE int], :uint
|
||||
attach_function :FPDFText_GetCharBox, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
|
||||
attach_function :FPDFText_GetCharOrigin, %i[FPDF_TEXTPAGE int pointer pointer], :int
|
||||
attach_function :FPDFText_GetCharIndexAtPos, %i[FPDF_TEXTPAGE double double double double], :int
|
||||
attach_function :FPDFText_CountRects, %i[FPDF_TEXTPAGE int int], :int
|
||||
attach_function :FPDFText_GetRect, %i[FPDF_TEXTPAGE int pointer pointer pointer pointer], :int
|
||||
attach_function :FPDFText_GetFontSize, %i[FPDF_TEXTPAGE int], :double
|
||||
|
||||
# Page object functions for extracting paths/lines
|
||||
attach_function :FPDFPage_CountObjects, [:FPDF_PAGE], :int
|
||||
attach_function :FPDFPage_GetObject, %i[FPDF_PAGE int], :FPDF_PAGEOBJECT
|
||||
attach_function :FPDFPageObj_GetType, [:FPDF_PAGEOBJECT], :int
|
||||
attach_function :FPDFPageObj_GetBounds, %i[FPDF_PAGEOBJECT pointer pointer pointer pointer], :int
|
||||
attach_function :FPDFPath_CountSegments, [:FPDF_PAGEOBJECT], :int
|
||||
attach_function :FPDFPath_GetPathSegment, %i[FPDF_PAGEOBJECT int], :FPDF_PATHSEGMENT
|
||||
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
|
||||
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
|
||||
|
||||
# Page object types
|
||||
FPDF_PAGEOBJ_UNKNOWN = 0
|
||||
FPDF_PAGEOBJ_TEXT = 1
|
||||
FPDF_PAGEOBJ_PATH = 2
|
||||
FPDF_PAGEOBJ_IMAGE = 3
|
||||
FPDF_PAGEOBJ_SHADING = 4
|
||||
FPDF_PAGEOBJ_FORM = 5
|
||||
|
||||
# Path segment types
|
||||
FPDF_SEGMENT_UNKNOWN = -1
|
||||
FPDF_SEGMENT_LINETO = 0
|
||||
FPDF_SEGMENT_BEZIERTO = 1
|
||||
FPDF_SEGMENT_MOVETO = 2
|
||||
|
||||
typedef :int, :FPDF_BOOL
|
||||
typedef :pointer, :IPDF_JSPLATFORM
|
||||
@@ -157,6 +193,7 @@ class Pdfium
|
||||
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics
|
||||
class Document
|
||||
attr_reader :document_ptr, :form_handle
|
||||
|
||||
@@ -386,6 +423,128 @@ class Pdfium
|
||||
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
|
||||
end
|
||||
|
||||
def text_nodes
|
||||
return @text_nodes if @text_nodes
|
||||
|
||||
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
|
||||
char_count = Pdfium.FPDFText_CountChars(text_page)
|
||||
|
||||
@text_nodes = []
|
||||
|
||||
return @text_nodes if char_count.zero?
|
||||
|
||||
char_count.times do |i|
|
||||
unicode = Pdfium.FPDFText_GetUnicode(text_page, i)
|
||||
|
||||
char = [unicode].pack('U*')
|
||||
|
||||
left_ptr = FFI::MemoryPointer.new(:double)
|
||||
right_ptr = FFI::MemoryPointer.new(:double)
|
||||
bottom_ptr = FFI::MemoryPointer.new(:double)
|
||||
top_ptr = FFI::MemoryPointer.new(:double)
|
||||
|
||||
result = Pdfium.FPDFText_GetCharBox(text_page, i, left_ptr, right_ptr, bottom_ptr, top_ptr)
|
||||
|
||||
next if result.zero?
|
||||
|
||||
left = left_ptr.read_double
|
||||
right = right_ptr.read_double
|
||||
|
||||
origin_x_ptr = FFI::MemoryPointer.new(:double)
|
||||
origin_y_ptr = FFI::MemoryPointer.new(:double)
|
||||
|
||||
Pdfium.FPDFText_GetCharOrigin(text_page, i, origin_x_ptr, origin_y_ptr)
|
||||
|
||||
origin_y = origin_y_ptr.read_double
|
||||
|
||||
font_size = Pdfium.FPDFText_GetFontSize(text_page, i)
|
||||
font_size = 8 if font_size == 1
|
||||
|
||||
abs_x = left
|
||||
abs_y = height - origin_y - (font_size * 0.8)
|
||||
abs_width = right - left
|
||||
abs_height = font_size
|
||||
|
||||
x = abs_x / width
|
||||
y = abs_y / height
|
||||
node_width = abs_width / width
|
||||
node_height = abs_height / height
|
||||
|
||||
@text_nodes << TextNode.new(content: char, x: x, y: y, w: node_width, h: node_height)
|
||||
end
|
||||
|
||||
@text_nodes = @text_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
|
||||
ensure
|
||||
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
|
||||
end
|
||||
|
||||
def line_nodes
|
||||
return @line_nodes if @line_nodes
|
||||
|
||||
ensure_not_closed!
|
||||
|
||||
@line_nodes = []
|
||||
|
||||
object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
|
||||
|
||||
return @line_nodes if object_count.zero?
|
||||
|
||||
object_count.times do |i|
|
||||
page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
|
||||
|
||||
next if page_object.null?
|
||||
|
||||
obj_type = Pdfium.FPDFPageObj_GetType(page_object)
|
||||
|
||||
next unless obj_type == Pdfium::FPDF_PAGEOBJ_PATH
|
||||
|
||||
left_ptr = FFI::MemoryPointer.new(:float)
|
||||
bottom_ptr = FFI::MemoryPointer.new(:float)
|
||||
right_ptr = FFI::MemoryPointer.new(:float)
|
||||
top_ptr = FFI::MemoryPointer.new(:float)
|
||||
|
||||
Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr)
|
||||
|
||||
obj_left = left_ptr.read_float
|
||||
obj_bottom = bottom_ptr.read_float
|
||||
obj_right = right_ptr.read_float
|
||||
obj_top = top_ptr.read_float
|
||||
|
||||
obj_width = obj_right - obj_left
|
||||
obj_height = obj_top - obj_bottom
|
||||
|
||||
next if obj_width < 1 && obj_height < 1
|
||||
|
||||
segment_count = Pdfium.FPDFPath_CountSegments(page_object)
|
||||
|
||||
next if segment_count < 2
|
||||
|
||||
next unless segment_count <= 10 && (obj_height < 10 || obj_width < 10)
|
||||
|
||||
if obj_width > obj_height && obj_height < 10
|
||||
tilt = 0
|
||||
elsif obj_height > obj_width && obj_width < 10
|
||||
tilt = 90
|
||||
else
|
||||
next
|
||||
end
|
||||
|
||||
x = obj_left
|
||||
y = obj_bottom
|
||||
w = obj_width
|
||||
h = obj_height
|
||||
|
||||
norm_x = x / width
|
||||
norm_y = (height - y - h) / height
|
||||
norm_w = w / width
|
||||
norm_h = h / height
|
||||
|
||||
@line_nodes << LineNode.new(x: norm_x, y: norm_y, w: norm_w, h: norm_h, tilt: tilt)
|
||||
end
|
||||
|
||||
@line_nodes = @line_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
|
||||
end
|
||||
|
||||
def close
|
||||
return if closed?
|
||||
|
||||
@@ -445,4 +604,5 @@ class Pdfium
|
||||
at_exit do
|
||||
cleanup_library
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
@@ -39,6 +39,7 @@ Puma::Plugin.create do
|
||||
configs = Sidekiq.configure_embed do |config|
|
||||
config.logger.level = Logger::INFO
|
||||
sidekiq_config = YAML.load_file('config/sidekiq.yml')
|
||||
sidekiq_config['queues'] << 'fields' if ENV['DEMO'] == 'true'
|
||||
config.queues = sidekiq_config['queues']
|
||||
config.concurrency = ENV.fetch('SIDEKIQ_THREADS', 5).to_i
|
||||
config.merge!(sidekiq_config)
|
||||
|
||||
@@ -56,7 +56,7 @@ module SearchEntries
|
||||
end
|
||||
|
||||
[sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword]
|
||||
elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.\-]{2,}/)
|
||||
elsif keyword.match?(/[^\p{L}\d&@.-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.-]{2,}/)
|
||||
['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)]
|
||||
else
|
||||
keyword = TextUtils.transliterate(keyword.downcase).squish
|
||||
|
||||
@@ -6,7 +6,7 @@ module SendWebhookRequest
|
||||
LOCALHOSTS = %w[0.0.0.0 127.0.0.1 localhost].freeze
|
||||
|
||||
MANUAL_ATTEMPT = 99_999
|
||||
AUTOMATED_RETRY_RANGE = 1..MANUAL_ATTEMPT - 1
|
||||
AUTOMATED_RETRY_RANGE = 1..(MANUAL_ATTEMPT - 1)
|
||||
|
||||
HttpsError = Class.new(StandardError)
|
||||
LocalhostError = Class.new(StandardError)
|
||||
|
||||
@@ -20,4 +20,14 @@ module SubmissionEvents
|
||||
**data
|
||||
}.compact_blank)
|
||||
end
|
||||
|
||||
def populate_account_id
|
||||
Account.find_each do |account|
|
||||
ids = account.submissions.pluck(:id)
|
||||
|
||||
ids.each_slice(10_000).each do |batch|
|
||||
SubmissionEvent.where(submission_id: batch).update_all(account_id: account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,6 +23,8 @@ module Submissions
|
||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||
MAX_IMAGE_HEIGHT = 100
|
||||
|
||||
CHECKSUM_LIMIT = 30
|
||||
|
||||
US_TIMEZONES = TimeUtils::US_TIMEZONES
|
||||
|
||||
module_function
|
||||
@@ -216,7 +218,7 @@ module Submissions
|
||||
composer.document.layout.formatted_text_box(
|
||||
[
|
||||
{ text: "#{I18n.t('original_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] },
|
||||
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"),
|
||||
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.first(CHECKSUM_LIMIT).join("\n"),
|
||||
"\n",
|
||||
{ text: "#{I18n.t('result_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] },
|
||||
document.metadata['sha256'] || document.checksum,
|
||||
@@ -481,7 +483,7 @@ module Submissions
|
||||
|
||||
def select_attachments(submitter)
|
||||
original_documents = submitter.submission.schema_documents.preload(:blob)
|
||||
is_more_than_two_images = original_documents.count(&:image?) > 1
|
||||
is_more_than_two_images = original_documents.many?(&:image?)
|
||||
|
||||
submitter.documents.preload(:blob).reject do |attachment|
|
||||
is_more_than_two_images &&
|
||||
|
||||
@@ -244,6 +244,7 @@ module Submissions
|
||||
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
|
||||
|
||||
fill_color = field.dig('preferences', 'color').to_s.delete_prefix('#').presence
|
||||
bg_color = field.dig('preferences', 'background').to_s.delete_prefix('#').presence
|
||||
|
||||
font_name = field.dig('preferences', 'font')
|
||||
font_variant = (field.dig('preferences', 'font_type').presence || 'none').to_sym
|
||||
@@ -289,6 +290,13 @@ module Submissions
|
||||
with_signature_id = field['preferences']['with_signature_id']
|
||||
end
|
||||
|
||||
if bg_color.present?
|
||||
canvas.fill_color(bg_color)
|
||||
.rectangle(area['x'] * width, height - (area['y'] * height) - (area['h'] * height),
|
||||
area['w'] * width, area['h'] * height)
|
||||
.fill
|
||||
end
|
||||
|
||||
case field_type
|
||||
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
|
||||
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||
@@ -311,8 +319,8 @@ module Submissions
|
||||
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
|
||||
|
||||
if with_signature_id_reason
|
||||
"#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \
|
||||
"#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \
|
||||
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
|
||||
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
|
||||
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
|
||||
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
|
||||
else
|
||||
@@ -321,9 +329,9 @@ module Submissions
|
||||
end
|
||||
end
|
||||
|
||||
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
|
||||
font:,
|
||||
font_size: (font_size / 1.8).to_i)
|
||||
base_font_size = (font_size / 1.8).to_i
|
||||
|
||||
result = nil
|
||||
|
||||
if area['h']&.positive? && (area['w'].to_f / area['h']) > 6
|
||||
area_x = area['x'] * width
|
||||
@@ -344,12 +352,10 @@ module Submissions
|
||||
|
||||
id_string = "ID: #{attachment.uuid}".upcase
|
||||
|
||||
while true
|
||||
text = HexaPDF::Layout::TextFragment.create(id_string,
|
||||
font:,
|
||||
font_size: (font_size / 1.8).to_i)
|
||||
loop do
|
||||
text = HexaPDF::Layout::TextFragment.create(id_string, font:, font_size: base_font_size)
|
||||
|
||||
result = layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
|
||||
result = layouter.fit([text], half_width, base_font_size / 0.65)
|
||||
|
||||
break if result.status == :success
|
||||
|
||||
@@ -358,25 +364,39 @@ module Submissions
|
||||
break if id_string.length < 8
|
||||
end
|
||||
|
||||
string = [id_string, reason_string].join("\n")
|
||||
|
||||
loop do
|
||||
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
|
||||
|
||||
result = layouter.fit([text], half_width, area_h)
|
||||
|
||||
break if result.status == :success
|
||||
|
||||
base_font_size *= 0.9
|
||||
|
||||
break if base_font_size < 2
|
||||
end
|
||||
|
||||
text = HexaPDF::Layout::TextFragment.create(string, font:, font_size: base_font_size)
|
||||
|
||||
text_x = area_x + half_width
|
||||
text_y = height - area_y
|
||||
|
||||
reason_result = layouter.fit([reason_text], half_width, height)
|
||||
|
||||
layouter.fit([text], half_width, (font_size / 1.8) / 0.65)
|
||||
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
|
||||
|
||||
layouter.fit([reason_text], half_width, reason_result.lines.sum(&:height))
|
||||
.draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y - TEXT_TOP_MARGIN - result.lines.sum(&:height))
|
||||
layouter.fit([text], half_width, area_h).draw(canvas, text_x + TEXT_LEFT_MARGIN, text_y)
|
||||
else
|
||||
reason_text = HexaPDF::Layout::TextFragment.create(reason_string,
|
||||
font:,
|
||||
font_size: base_font_size)
|
||||
|
||||
id_string = "ID: #{attachment.uuid}".upcase
|
||||
|
||||
loop do
|
||||
text = HexaPDF::Layout::TextFragment.create(id_string,
|
||||
font:,
|
||||
font_size: (font_size / 1.8).to_i)
|
||||
font_size: base_font_size)
|
||||
|
||||
result = layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
|
||||
result = layouter.fit([text], area['w'] * width, base_font_size / 0.65)
|
||||
|
||||
break if result.status == :success
|
||||
|
||||
@@ -395,7 +415,7 @@ module Submissions
|
||||
|
||||
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
|
||||
|
||||
layouter.fit([text], area['w'] * width, (font_size / 1.8) / 0.65)
|
||||
layouter.fit([text], area['w'] * width, base_font_size / 0.65)
|
||||
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
|
||||
height - (area['y'] * height) - TEXT_TOP_MARGIN - image_height)
|
||||
|
||||
@@ -699,20 +719,30 @@ module Submissions
|
||||
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false }, **sign_params)
|
||||
rescue HexaPDF::MalformedPDFError, NoMethodError => e
|
||||
rescue HexaPDF::Error, NoMethodError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
begin
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
rescue HexaPDF::Error
|
||||
pdf.validate(auto_correct: true)
|
||||
pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
|
||||
end
|
||||
end
|
||||
|
||||
maybe_enable_ltv(io, sign_params)
|
||||
else
|
||||
begin
|
||||
pdf.write(io, incremental: true, validate: false)
|
||||
rescue HexaPDF::MalformedPDFError, NoMethodError => e
|
||||
rescue HexaPDF::Error, NoMethodError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
begin
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
rescue HexaPDF::Error
|
||||
pdf.validate(auto_correct: true)
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -833,7 +863,7 @@ module Submissions
|
||||
def find_last_submitter(submission, submitter: nil)
|
||||
submission.submitters
|
||||
.select(&:completed_at?)
|
||||
.select { |e| submitter.nil? ? true : e.id != submitter.id && e.completed_at <= submitter.completed_at }
|
||||
.select { |e| submitter.nil? || (e.id != submitter.id && e.completed_at <= submitter.completed_at) }
|
||||
.max_by(&:completed_at)
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module Submissions
|
||||
class TimestampHandler
|
||||
HASH_ALGORITHM = 'SHA256'
|
||||
TIMEOUT = 10
|
||||
|
||||
TimestampError = Class.new(StandardError)
|
||||
|
||||
@@ -32,6 +33,8 @@ module Submissions
|
||||
uri = Addressable::URI.parse(tsa_url)
|
||||
|
||||
conn = Faraday.new(uri.origin) do |c|
|
||||
c.options.read_timeout = TIMEOUT
|
||||
c.options.open_timeout = TIMEOUT
|
||||
c.basic_auth(uri.user, uri.password) if uri.password.present?
|
||||
end
|
||||
|
||||
|
||||
+35
-2
@@ -13,6 +13,17 @@ module Submitters
|
||||
|
||||
UnableToSendCode = Class.new(StandardError)
|
||||
InvalidOtp = Class.new(StandardError)
|
||||
MaliciousFileExtension = Class.new(StandardError)
|
||||
|
||||
DANGEROUS_EXTENSIONS = Set.new(%w[
|
||||
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
|
||||
hta cpl jar app deb rpm dmg pkg mpkg dll so dylib sys
|
||||
inf reg ps1 psm1 psd1 ps1xml psc1 pssc bat cmd vb vba
|
||||
sh bash zsh fish run out bin elf gadget workflow lnk scf
|
||||
url desktop application action workflow apk ipa xap appx
|
||||
appxbundle msix msixbundle diagcab diagpkg cpl msc ocx
|
||||
drv scr ins isp mst paf prf shb shs slk ws wsc inf1 inf2
|
||||
].freeze)
|
||||
|
||||
module_function
|
||||
|
||||
@@ -56,7 +67,7 @@ module Submitters
|
||||
end
|
||||
|
||||
[sql, number, weight, number.length > 1 ? number.delete_prefix('0') : number, weight]
|
||||
elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/[.\-]{2,}/)
|
||||
elsif keyword.match?(/[^\p{L}\d&@.-]/) || keyword.match?(/[.-]{2,}/)
|
||||
terms = TextUtils.transliterate(keyword.downcase).split(/\b/).map(&:squish).compact_blank.uniq
|
||||
|
||||
if terms.size > 1
|
||||
@@ -100,7 +111,7 @@ module Submitters
|
||||
end
|
||||
|
||||
original_documents = submitter.submission.schema_documents.preload(:blob)
|
||||
is_more_than_two_images = original_documents.count(&:image?) > 1
|
||||
is_more_than_two_images = original_documents.many?(&:image?)
|
||||
|
||||
submitter.documents.preload(:blob).reject do |attachment|
|
||||
is_more_than_two_images &&
|
||||
@@ -111,6 +122,12 @@ module Submitters
|
||||
def create_attachment!(submitter, params)
|
||||
blob =
|
||||
if (file = params[:file])
|
||||
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||
|
||||
if DANGEROUS_EXTENSIONS.include?(extension)
|
||||
raise MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||
end
|
||||
|
||||
ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type)
|
||||
@@ -234,4 +251,20 @@ module Submitters
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def populate_completed_is_first
|
||||
Account.find_each do |account|
|
||||
submissions_index = {}
|
||||
|
||||
CompletedSubmitter.where(account_id: account.id).order(:account_id, :completed_at).each do |cs|
|
||||
submissions_index[cs.submission_id] ||= cs.submitter_id
|
||||
|
||||
cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
CompletedSubmitter.where(submission_id: cs.submission_id).update_all(is_first: false)
|
||||
|
||||
cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -20,6 +20,8 @@ module Submitters
|
||||
def call(text, font: nil)
|
||||
font = FONT_ALIASES[font] || font
|
||||
|
||||
text = ERB::Util.html_escape(text)
|
||||
|
||||
text_image = Vips::Image.text(text, font:, fontfile: FONTS[font],
|
||||
width: WIDTH, height: HEIGHT, wrap: :none)
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ module Submitters
|
||||
end
|
||||
|
||||
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
|
||||
if submitter_name
|
||||
if submitter_name && !for_submitter
|
||||
submitter =
|
||||
template.submitters.find { |e| e['name'] == submitter_name } ||
|
||||
raise(UnknownSubmitterName,
|
||||
|
||||
Executable
+485
@@ -0,0 +1,485 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module DetectFields
|
||||
module_function
|
||||
|
||||
TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true)
|
||||
PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid, keyword_init: true)
|
||||
|
||||
DATE_REGEXP = /
|
||||
(?:
|
||||
date
|
||||
| signed\sat
|
||||
| datum
|
||||
)
|
||||
\s*[:-]?\s*\z
|
||||
/ix
|
||||
|
||||
NUMBER_REGEXP = /
|
||||
(?:
|
||||
price
|
||||
| \$
|
||||
| €
|
||||
| total
|
||||
| quantity
|
||||
| prix
|
||||
| quantité
|
||||
| preis
|
||||
| summe
|
||||
| gesamt(?:betrag)?
|
||||
| menge
|
||||
| anzahl
|
||||
| stückzahl
|
||||
)
|
||||
\s*[:-]?\s*\z
|
||||
/ix
|
||||
|
||||
SIGNATURE_REGEXP = /
|
||||
(?:
|
||||
signature
|
||||
| sign\shere
|
||||
| sign
|
||||
| signez\sici
|
||||
| signer\sici
|
||||
| unterschrift
|
||||
| unterschreiben
|
||||
| unterzeichnen
|
||||
)
|
||||
\s*[:-]?\s*\z
|
||||
/ix
|
||||
|
||||
# rubocop:disable Metrics, Style
|
||||
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields,
|
||||
nms: 0.1, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &)
|
||||
fields, head_node =
|
||||
if attachment&.image?
|
||||
process_image_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
|
||||
temperature:, aspect_ratio:, padding:, &)
|
||||
else
|
||||
process_pdf_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
|
||||
temperature:, aspect_ratio:, regexp_type:, padding:, &)
|
||||
end
|
||||
|
||||
[fields, head_node]
|
||||
end
|
||||
|
||||
def process_image_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
|
||||
split_page: false, aspect_ratio: false, padding: nil)
|
||||
image = Vips::Image.new_from_buffer(io.read, '')
|
||||
|
||||
fields = inference.call(image, confidence:, nms:, split_page:,
|
||||
temperature:, aspect_ratio:, padding:)
|
||||
|
||||
fields = fields.map do |f|
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
type: f.type,
|
||||
required: f.type != 'checkbox',
|
||||
preferences: {},
|
||||
areas: [{
|
||||
x: f.x,
|
||||
y: f.y,
|
||||
w: f.w,
|
||||
h: f.h,
|
||||
page: 0,
|
||||
attachment_uuid: attachment&.uuid
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
yield [attachment&.uuid, 0, fields] if block_given?
|
||||
|
||||
[fields, nil]
|
||||
end
|
||||
|
||||
def process_pdf_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
|
||||
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false)
|
||||
doc = Pdfium::Document.open_bytes(io.read)
|
||||
|
||||
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
|
||||
tail_node = head_node
|
||||
|
||||
fields = doc.page_count.times.flat_map do |page_number|
|
||||
page = doc.get_page(page_number)
|
||||
|
||||
data, width, height = page.render_to_bitmap(width: inference::RESOLUTION * 1.5)
|
||||
|
||||
image = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
|
||||
|
||||
fields = inference.call(image, confidence: confidence / 4.0, nms:, split_page:,
|
||||
temperature:, aspect_ratio:, padding:)
|
||||
|
||||
text_fields = extract_text_fields_from_page(page)
|
||||
line_fields = extract_line_fields_from_page(page)
|
||||
|
||||
fields = increase_confidence_for_overlapping_fields(fields, text_fields)
|
||||
fields = increase_confidence_for_overlapping_fields(fields, line_fields)
|
||||
|
||||
fields = fields.reject { |f| f.confidence < confidence }
|
||||
|
||||
field_nodes, tail_node = build_page_nodes(page, fields, tail_node, attachment_uuid: attachment&.uuid)
|
||||
|
||||
fields = field_nodes.map do |node|
|
||||
field = node.elem
|
||||
|
||||
type = regexp_type ? type_from_page_node(node) : field.type
|
||||
|
||||
{
|
||||
uuid: SecureRandom.uuid,
|
||||
type:,
|
||||
required: type != 'checkbox',
|
||||
preferences: {},
|
||||
areas: [{
|
||||
x: field.x, y: field.y,
|
||||
w: field.w, h: field.h,
|
||||
page: page_number,
|
||||
attachment_uuid: attachment&.uuid
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
yield [attachment&.uuid, page_number, fields] if block_given?
|
||||
|
||||
fields
|
||||
ensure
|
||||
page.close
|
||||
end
|
||||
|
||||
print_debug(head_node) if Rails.env.development?
|
||||
|
||||
[fields, head_node]
|
||||
ensure
|
||||
doc.close
|
||||
end
|
||||
|
||||
def print_debug(head_node)
|
||||
current_node = head_node
|
||||
index = 0
|
||||
string = ''.b
|
||||
|
||||
loop do
|
||||
string <<
|
||||
if current_node.elem.is_a?(String)
|
||||
current_node.elem
|
||||
else
|
||||
"[#{current_node.elem.type == 'checkbox' ? 'Checkbox' : 'Field'}_#{index += 1}]"
|
||||
end
|
||||
|
||||
current_node = current_node.next
|
||||
|
||||
break unless current_node
|
||||
end
|
||||
|
||||
Rails.logger.info(string)
|
||||
end
|
||||
|
||||
def type_from_page_node(node)
|
||||
return node.elem.type unless node.prev.elem.is_a?(String)
|
||||
return node.elem.type unless node.elem.type == 'text'
|
||||
|
||||
string = node.prev.elem
|
||||
|
||||
return 'date' if string.match?(DATE_REGEXP)
|
||||
return 'signature' if string.match?(SIGNATURE_REGEXP)
|
||||
return 'number' if string.match?(NUMBER_REGEXP)
|
||||
|
||||
return 'text'
|
||||
end
|
||||
|
||||
def build_page_nodes(page, fields, tail_node, attachment_uuid: nil)
|
||||
field_nodes = []
|
||||
current_text = ''.b
|
||||
|
||||
text_nodes = page.text_nodes
|
||||
|
||||
text_idx = 0
|
||||
field_idx = 0
|
||||
|
||||
while text_idx < text_nodes.length || field_idx < fields.length
|
||||
text_node = text_nodes[text_idx]
|
||||
field = fields[field_idx]
|
||||
|
||||
process_text_node = false
|
||||
process_field_node = false
|
||||
|
||||
if text_node && field
|
||||
text_y_center = text_node.y + (text_node.h / 2.0)
|
||||
field_y_center = field.y + (field.h / 2.0)
|
||||
y_threshold = text_node.h / 2.0
|
||||
vertical_distance = (text_y_center - field_y_center).abs
|
||||
|
||||
if vertical_distance < y_threshold
|
||||
is_underscore = text_node.content == '_'
|
||||
is_left_of_field = text_node.x < field.x
|
||||
|
||||
if is_underscore && is_left_of_field
|
||||
text_x_end = text_node.x + text_node.w
|
||||
|
||||
distance = field.x - text_x_end
|
||||
proximity_threshold = text_node.w * 3.0
|
||||
|
||||
if distance < proximity_threshold
|
||||
process_field_node = true
|
||||
else
|
||||
process_text_node = true
|
||||
end
|
||||
|
||||
elsif is_left_of_field
|
||||
process_text_node = true
|
||||
else
|
||||
process_field_node = true
|
||||
end
|
||||
|
||||
elsif text_node.y < field.y
|
||||
process_text_node = true
|
||||
else
|
||||
process_field_node = true
|
||||
end
|
||||
|
||||
elsif text_node
|
||||
process_text_node = true
|
||||
elsif field
|
||||
process_field_node = true
|
||||
end
|
||||
|
||||
if process_field_node
|
||||
unless current_text.empty?
|
||||
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
|
||||
tail_node.next = new_text_node
|
||||
tail_node = new_text_node
|
||||
current_text = ''.b
|
||||
end
|
||||
|
||||
new_field_node = PageNode.new(prev: tail_node, elem: field, page: page.page_index, attachment_uuid:)
|
||||
tail_node.next = new_field_node
|
||||
tail_node = new_field_node
|
||||
|
||||
field_nodes << tail_node
|
||||
|
||||
while text_idx < text_nodes.length
|
||||
text_node_to_check = text_nodes[text_idx]
|
||||
|
||||
is_part_of_field = false
|
||||
|
||||
if text_node_to_check.content == '_'
|
||||
check_y_center = text_node_to_check.y + (text_node_to_check.h / 2.0)
|
||||
check_y_dist = (check_y_center - field_y_center).abs
|
||||
check_y_thresh = text_node_to_check.h / 2.0
|
||||
|
||||
if check_y_dist < check_y_thresh
|
||||
padding = text_node_to_check.w * 3.0
|
||||
field_x_start = field.x - padding
|
||||
field_x_end = field.x + field.w + padding
|
||||
text_x_start = text_node_to_check.x
|
||||
text_x_end = text_node_to_check.x + text_node_to_check.w
|
||||
|
||||
is_part_of_field = true if text_x_start <= field_x_end && field_x_start <= text_x_end
|
||||
end
|
||||
end
|
||||
|
||||
break unless is_part_of_field
|
||||
|
||||
text_idx += 1
|
||||
end
|
||||
|
||||
field_idx += 1
|
||||
elsif process_text_node
|
||||
if text_idx > 0
|
||||
prev_text_node = text_nodes[text_idx - 1]
|
||||
|
||||
x_gap = text_node.x - (prev_text_node.x + prev_text_node.w)
|
||||
|
||||
gap_w = text_node.w > prev_text_node.w ? text_node.w : prev_text_node.w
|
||||
|
||||
current_text << ' ' if x_gap > gap_w * 2
|
||||
end
|
||||
|
||||
current_text << text_node.content
|
||||
text_idx += 1
|
||||
end
|
||||
end
|
||||
|
||||
unless current_text.empty?
|
||||
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
|
||||
tail_node.next = new_text_node
|
||||
tail_node = new_text_node
|
||||
end
|
||||
|
||||
[field_nodes, tail_node]
|
||||
end
|
||||
|
||||
def extract_line_fields_from_page(page)
|
||||
line_thickness = 5.0 / page.height
|
||||
|
||||
vertical_lines, all_horizontal_lines = page.line_nodes.partition { |line| line.tilt == 90 }
|
||||
|
||||
horizontal_lines = all_horizontal_lines.reject do |h_line|
|
||||
next true if h_line.w > 0.7 && (h_line.h < 0.1 || h_line.h < 0.9)
|
||||
|
||||
next false if vertical_lines.blank?
|
||||
|
||||
h_x_min = h_line.x
|
||||
h_x_max = h_line.x + h_line.w
|
||||
h_y_avg = h_line.y + (h_line.h / 2)
|
||||
|
||||
vertical_lines.any? do |v_line|
|
||||
v_x_avg = v_line.x + (v_line.w / 2)
|
||||
v_y_min = v_line.y
|
||||
v_y_max = v_line.y + v_line.h
|
||||
|
||||
h_x_min_expanded = h_x_min - line_thickness
|
||||
h_x_max_expanded = h_x_max + line_thickness
|
||||
h_y_min_expanded = h_y_avg - line_thickness
|
||||
h_y_max_expanded = h_y_avg + line_thickness
|
||||
|
||||
v_x_min_expanded = v_x_avg - line_thickness
|
||||
v_x_max_expanded = v_x_avg + line_thickness
|
||||
v_y_min_expanded = v_y_min - line_thickness
|
||||
v_y_max_expanded = v_y_max + line_thickness
|
||||
|
||||
x_overlap = v_x_min_expanded <= h_x_max_expanded && v_x_max_expanded >= h_x_min_expanded
|
||||
y_overlap = h_y_min_expanded <= v_y_max_expanded && h_y_max_expanded >= v_y_min_expanded
|
||||
|
||||
x_overlap && y_overlap
|
||||
end
|
||||
end
|
||||
|
||||
node_index = 0
|
||||
|
||||
horizontal_lines = horizontal_lines.reject do |line|
|
||||
nodes = []
|
||||
|
||||
loop do
|
||||
node = page.text_nodes[node_index += 1]
|
||||
|
||||
break unless node
|
||||
|
||||
break if node.y > line.y
|
||||
|
||||
next if node.x + node.w < line.x || line.x + line.w < node.x ||
|
||||
node.y + node.h < line.y - node.h || line.y < node.y
|
||||
|
||||
nodes << node
|
||||
|
||||
next if nodes.blank?
|
||||
|
||||
next_node = page.text_nodes[node_index + 1]
|
||||
|
||||
break unless next_node
|
||||
|
||||
break if next_node.x + next_node.w < line.x || line.x + line.w < next_node.x ||
|
||||
next_node.y + next_node.h < line.y - next_node.h || line.y < next_node.y
|
||||
end
|
||||
|
||||
next if nodes.blank?
|
||||
|
||||
width = nodes.last.x + nodes.last.w - nodes.first.x
|
||||
|
||||
next true if width > line.w / 2.0
|
||||
end
|
||||
|
||||
horizontal_lines.each do |line|
|
||||
line.h += 4 * line_thickness
|
||||
line.y -= 4 * line_thickness
|
||||
end
|
||||
end
|
||||
|
||||
def extract_text_fields_from_page(page)
|
||||
text_nodes = page.text_nodes
|
||||
|
||||
field_boxes = []
|
||||
|
||||
i = 0
|
||||
|
||||
while i < text_nodes.length
|
||||
node = text_nodes[i]
|
||||
|
||||
next i += 1 if node.content != '_'
|
||||
|
||||
x1 = node.x
|
||||
y1 = node.y
|
||||
x2 = node.x + node.w
|
||||
y2 = node.y + node.h
|
||||
|
||||
underscore_count = 1
|
||||
|
||||
j = i + 1
|
||||
|
||||
while j < text_nodes.length
|
||||
next_node = text_nodes[j]
|
||||
|
||||
break unless next_node.content == '_'
|
||||
|
||||
distance = next_node.x - x2
|
||||
height_diff = (next_node.y - y1).abs
|
||||
|
||||
break if distance > 0.02 || height_diff > node.h * 0.5
|
||||
|
||||
underscore_count += 1
|
||||
next_x2 = next_node.x + next_node.w
|
||||
next_y2 = next_node.y + next_node.h
|
||||
|
||||
x2 = next_x2
|
||||
y2 = [y2, next_y2].max
|
||||
y1 = [y1, next_node.y].min
|
||||
|
||||
j += 1
|
||||
end
|
||||
|
||||
field_boxes << TextFieldBox.new(x: x1, y: y1, w: x2 - x1, h: y2 - y1) if underscore_count >= 2
|
||||
|
||||
i = j
|
||||
end
|
||||
|
||||
field_boxes
|
||||
end
|
||||
|
||||
def calculate_iou(box1, box2)
|
||||
x1 = [box1.x, box2.x].max
|
||||
y1 = [box1.y, box2.y].max
|
||||
x2 = [box1.x + box1.w, box2.x + box2.w].min
|
||||
y2 = [box1.y + box1.h, box2.y + box2.h].min
|
||||
|
||||
intersection_width = [0, x2 - x1].max
|
||||
intersection_height = [0, y2 - y1].max
|
||||
intersection_area = intersection_width * intersection_height
|
||||
|
||||
return 0.0 if intersection_area.zero?
|
||||
|
||||
box1_area = box1.w * box1.h
|
||||
box2_area = box2.w * box2.h
|
||||
union_area = box1_area + box2_area - intersection_area
|
||||
|
||||
intersection_area / union_area
|
||||
end
|
||||
|
||||
def boxes_overlap?(box1, box2)
|
||||
!(box1.x + box1.w < box2.x || box2.x + box2.w < box1.x ||
|
||||
box1.y + box1.h < box2.y || box2.y + box2.h < box1.y)
|
||||
end
|
||||
|
||||
def increase_confidence_for_overlapping_fields(image_fields, text_fields, by: 1.0)
|
||||
return image_fields if text_fields.blank?
|
||||
|
||||
image_fields.map do |image_field|
|
||||
next if image_field.type != 'text'
|
||||
|
||||
field_bottom = image_field.y + image_field.h
|
||||
|
||||
text_fields.each do |text_field|
|
||||
break if text_field.y > field_bottom
|
||||
|
||||
next if text_field.y + text_field.h < image_field.y
|
||||
|
||||
next unless boxes_overlap?(image_field, text_field) && calculate_iou(image_field, text_field) > 0.5
|
||||
|
||||
break image_field.confidence += by
|
||||
end
|
||||
end
|
||||
|
||||
image_fields
|
||||
end
|
||||
# rubocop:enable Metrics, Style
|
||||
end
|
||||
end
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module ImageToFields
|
||||
module_function
|
||||
|
||||
Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true)
|
||||
|
||||
MODEL_PATH = Rails.root.join('tmp/model.onnx')
|
||||
|
||||
RESOLUTION = 704
|
||||
|
||||
ID_TO_CLASS = %w[text checkbox].freeze
|
||||
|
||||
MEAN = [0.485, 0.456, 0.406].freeze
|
||||
STD = [0.229, 0.224, 0.225].freeze
|
||||
|
||||
CPU_THREADS = Etc.nprocessors
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(image, confidence: 0.3, nms: 0.1, temperature: 1,
|
||||
split_page: false, aspect_ratio: true, padding: nil, resolution: RESOLUTION)
|
||||
base_image = image.extract_band(0, n: 3)
|
||||
|
||||
trimmed_base, base_offset_x, base_offset_y = trim_image_with_padding(base_image, padding)
|
||||
|
||||
if split_page && image.height > image.width
|
||||
regions = build_split_image_regions(trimmed_base)
|
||||
|
||||
detections = { xyxy: Numo::SFloat[], confidence: Numo::SFloat[], class_id: Numo::Int32[] }
|
||||
|
||||
detections = regions.reduce(detections) do |acc, r|
|
||||
next detections if r[:img].height <= 0 || r[:img].width <= 0
|
||||
|
||||
input_tensor, transform_info = preprocess_image(r[:img], resolution, aspect_ratio:)
|
||||
|
||||
transform_info[:trim_offset_x] = base_offset_x
|
||||
transform_info[:trim_offset_y] = base_offset_y + r[:offset_y]
|
||||
|
||||
outputs = model.predict({ 'input' => input_tensor }, output_type: :numo)
|
||||
|
||||
boxes = outputs['dets'][0, true, true]
|
||||
logits = outputs['labels'][0, true, true]
|
||||
|
||||
postprocess_outputs(boxes, logits, transform_info, acc, confidence:, temperature:, resolution:)
|
||||
end
|
||||
else
|
||||
input_tensor, transform_info = preprocess_image(trimmed_base, resolution, aspect_ratio:)
|
||||
|
||||
transform_info[:trim_offset_x] = base_offset_x
|
||||
transform_info[:trim_offset_y] = base_offset_y
|
||||
|
||||
outputs = model.predict({ 'input' => input_tensor }, output_type: :numo)
|
||||
|
||||
boxes = outputs['dets'][0, true, true]
|
||||
logits = outputs['labels'][0, true, true]
|
||||
|
||||
detections = postprocess_outputs(boxes, logits, transform_info, confidence:, temperature:, resolution:)
|
||||
end
|
||||
|
||||
detections = apply_nms(detections, nms)
|
||||
|
||||
fields = build_fields_from_detections(detections, image)
|
||||
|
||||
sort_fields(fields, y_threshold: 10.0 / image.height)
|
||||
end
|
||||
|
||||
def build_split_image_regions(image)
|
||||
half_h = image.height / 2
|
||||
top_h = half_h
|
||||
bottom_h = image.height - half_h
|
||||
|
||||
[
|
||||
{ img: image.crop(0, 0, image.width, top_h), offset_y: 0 },
|
||||
{ img: image.crop(0, top_h, image.width, bottom_h), offset_y: top_h }
|
||||
]
|
||||
end
|
||||
|
||||
def build_fields_from_detections(detections, image)
|
||||
detections[:xyxy].shape[0].times.filter_map do |i|
|
||||
x1 = detections[:xyxy][i, 0]
|
||||
y1 = detections[:xyxy][i, 1]
|
||||
x2 = detections[:xyxy][i, 2]
|
||||
y2 = detections[:xyxy][i, 3]
|
||||
|
||||
class_id = detections[:class_id][i].to_i
|
||||
|
||||
confidence = detections[:confidence][i]
|
||||
|
||||
x0_norm = x1 / image.width.to_f
|
||||
y0_norm = y1 / image.height.to_f
|
||||
x1_norm = x2 / image.width.to_f
|
||||
y1_norm = y2 / image.height.to_f
|
||||
|
||||
x1_norm = 1 if x1_norm > 1
|
||||
y1_norm = 1 if y1_norm > 1
|
||||
|
||||
next if x0_norm < 0 || x0_norm > 1
|
||||
next if y0_norm < 0 || y0_norm > 1
|
||||
|
||||
type_name = ID_TO_CLASS[class_id]
|
||||
|
||||
Field.new(
|
||||
type: type_name,
|
||||
x: x0_norm,
|
||||
y: y0_norm,
|
||||
w: (x1_norm - x0_norm),
|
||||
h: (y1_norm - y0_norm),
|
||||
confidence:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def trim_image_with_padding(image, padding = 0)
|
||||
return [image, 0, 0] if padding.nil?
|
||||
|
||||
left, top, trim_width, trim_height = image.find_trim(threshold: 10, background: [255, 255, 255])
|
||||
|
||||
padded_left = [left - padding, 0].max
|
||||
padded_top = [top - padding, 0].max
|
||||
padded_right = [left + trim_width + padding, image.width].min
|
||||
padded_bottom = [top + trim_height + padding, image.height].min
|
||||
|
||||
width = padded_right - padded_left
|
||||
height = padded_bottom - padded_top
|
||||
|
||||
trimmed_image = image.crop(padded_left, padded_top, width, height)
|
||||
|
||||
[trimmed_image, padded_left, padded_top]
|
||||
end
|
||||
|
||||
def preprocess_image(image, resolution, aspect_ratio: false)
|
||||
scale_x = resolution.to_f / image.width
|
||||
scale_y = resolution.to_f / image.height
|
||||
|
||||
if aspect_ratio
|
||||
scale = [scale_x, scale_y].min
|
||||
|
||||
new_width = (image.width * scale).round
|
||||
new_height = (image.height * scale).round
|
||||
|
||||
resized = image.resize(scale, vscale: scale, kernel: :lanczos3)
|
||||
|
||||
pad_x = ((resolution - new_width) / 2.0).round
|
||||
pad_y = ((resolution - new_height) / 2.0).round
|
||||
|
||||
image = resized.embed(pad_x, pad_y, resolution, resolution, background: [255, 255, 255])
|
||||
|
||||
transform_info = { scale_x: scale, scale_y: scale, pad_x: pad_x, pad_y: pad_y }
|
||||
else
|
||||
image = image.resize(scale_x, vscale: scale_y, kernel: :lanczos3)
|
||||
|
||||
transform_info = { scale_x: scale_x, scale_y: scale_y, pad_x: 0, pad_y: 0 }
|
||||
end
|
||||
|
||||
image /= 255.0
|
||||
|
||||
image = (image - MEAN) / STD
|
||||
|
||||
pixel_data = image.write_to_memory
|
||||
|
||||
img_array = Numo::SFloat.from_binary(pixel_data, [resolution, resolution, 3])
|
||||
|
||||
img_array = img_array.transpose(2, 0, 1)
|
||||
|
||||
[img_array.reshape(1, 3, resolution, resolution), transform_info]
|
||||
end
|
||||
|
||||
def nms(boxes, scores, iou_threshold = 0.5)
|
||||
return Numo::Int32[] if boxes.shape[0].zero?
|
||||
|
||||
x1 = boxes[true, 0]
|
||||
y1 = boxes[true, 1]
|
||||
x2 = boxes[true, 2]
|
||||
y2 = boxes[true, 3]
|
||||
|
||||
areas = (x2 - x1) * (y2 - y1)
|
||||
order = scores.sort_index.reverse
|
||||
|
||||
keep = []
|
||||
|
||||
while order.size.positive?
|
||||
i = order[0]
|
||||
keep << i
|
||||
|
||||
break if order.size == 1
|
||||
|
||||
xx1 = Numo::SFloat.maximum(x1[i], x1[order[1..]])
|
||||
yy1 = Numo::SFloat.maximum(y1[i], y1[order[1..]])
|
||||
xx2 = Numo::SFloat.minimum(x2[i], x2[order[1..]])
|
||||
yy2 = Numo::SFloat.minimum(y2[i], y2[order[1..]])
|
||||
|
||||
w = Numo::SFloat.maximum(0.0, xx2 - xx1)
|
||||
h = Numo::SFloat.maximum(0.0, yy2 - yy1)
|
||||
|
||||
intersection = w * h
|
||||
|
||||
iou = intersection / (areas[i] + areas[order[1..]] - intersection)
|
||||
|
||||
inds = iou.le(iou_threshold).where
|
||||
|
||||
order = order[inds + 1]
|
||||
end
|
||||
|
||||
Numo::Int32.cast(keep)
|
||||
end
|
||||
|
||||
def postprocess_outputs(boxes, logits, transform_info, detections = nil, confidence: 0.3, temperature: 1,
|
||||
resolution: RESOLUTION)
|
||||
scaled_logits = logits / temperature
|
||||
|
||||
probs = 1.0 / (1.0 + Numo::NMath.exp(-scaled_logits))
|
||||
|
||||
scores = probs.max(axis: 1)
|
||||
labels = probs.argmax(axis: 1)
|
||||
|
||||
cx = boxes[true, 0]
|
||||
cy = boxes[true, 1]
|
||||
w = boxes[true, 2]
|
||||
h = boxes[true, 3]
|
||||
|
||||
x1 = cx - (w / 2.0)
|
||||
y1 = cy - (h / 2.0)
|
||||
x2 = cx + (w / 2.0)
|
||||
y2 = cy + (h / 2.0)
|
||||
|
||||
boxes_xyxy = Numo::SFloat.zeros(boxes.shape[0], 4)
|
||||
boxes_xyxy[true, 0] = x1
|
||||
boxes_xyxy[true, 1] = y1
|
||||
boxes_xyxy[true, 2] = x2
|
||||
boxes_xyxy[true, 3] = y2
|
||||
|
||||
boxes_xyxy *= resolution
|
||||
|
||||
pad_x = transform_info[:pad_x]
|
||||
pad_y = transform_info[:pad_y]
|
||||
boxes_xyxy[true, 0] -= pad_x
|
||||
boxes_xyxy[true, 1] -= pad_y
|
||||
boxes_xyxy[true, 2] -= pad_x
|
||||
boxes_xyxy[true, 3] -= pad_y
|
||||
|
||||
scale_x = transform_info[:scale_x]
|
||||
scale_y = transform_info[:scale_y]
|
||||
boxes_xyxy[true, 0] /= scale_x
|
||||
boxes_xyxy[true, 1] /= scale_y
|
||||
boxes_xyxy[true, 2] /= scale_x
|
||||
boxes_xyxy[true, 3] /= scale_y
|
||||
|
||||
trim_offset_x = transform_info[:trim_offset_x]
|
||||
trim_offset_y = transform_info[:trim_offset_y]
|
||||
boxes_xyxy[true, 0] += trim_offset_x
|
||||
boxes_xyxy[true, 1] += trim_offset_y
|
||||
boxes_xyxy[true, 2] += trim_offset_x
|
||||
boxes_xyxy[true, 3] += trim_offset_y
|
||||
|
||||
keep_mask = scores.gt(confidence)
|
||||
|
||||
keep_indices = keep_mask.where
|
||||
|
||||
if keep_indices.empty?
|
||||
detections || {
|
||||
xyxy: Numo::SFloat[],
|
||||
confidence: Numo::SFloat[],
|
||||
class_id: Numo::Int32[]
|
||||
}
|
||||
else
|
||||
scores = scores[keep_indices]
|
||||
labels = labels[keep_indices]
|
||||
boxes_xyxy = boxes_xyxy[keep_indices, true]
|
||||
|
||||
if detections
|
||||
existing_n = detections[:xyxy].shape[0]
|
||||
new_n = boxes_xyxy.shape[0]
|
||||
total = existing_n + new_n
|
||||
|
||||
xyxy = Numo::SFloat.zeros(total, 4)
|
||||
conf = Numo::SFloat.zeros(total)
|
||||
cls = Numo::Int32.zeros(total)
|
||||
|
||||
if existing_n.positive?
|
||||
xyxy[0...existing_n, true] = detections[:xyxy]
|
||||
conf[0...existing_n] = detections[:confidence]
|
||||
cls[0...existing_n] = detections[:class_id]
|
||||
end
|
||||
|
||||
xyxy[existing_n...(existing_n + new_n), true] = boxes_xyxy
|
||||
conf[existing_n...(existing_n + new_n)] = scores
|
||||
cls[existing_n...(existing_n + new_n)] = Numo::Int32.cast(labels)
|
||||
|
||||
{ xyxy: xyxy, confidence: conf, class_id: cls }
|
||||
else
|
||||
{
|
||||
xyxy: boxes_xyxy,
|
||||
confidence: scores,
|
||||
class_id: Numo::Int32.cast(labels)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sort_fields(fields, y_threshold: 0.01)
|
||||
sorted_fields = fields.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
|
||||
|
||||
lines = []
|
||||
current_line = []
|
||||
|
||||
sorted_fields.each do |field|
|
||||
if current_line.blank? || (field.y - current_line.first.y).abs < y_threshold
|
||||
current_line << field
|
||||
else
|
||||
lines << current_line.sort_by(&:x)
|
||||
|
||||
current_line = [field]
|
||||
end
|
||||
end
|
||||
|
||||
lines << current_line.sort_by(&:x) if current_line.present?
|
||||
|
||||
lines.flatten
|
||||
end
|
||||
|
||||
def apply_nms(detections, threshold = 0.5)
|
||||
return detections if detections[:xyxy].shape[0].zero?
|
||||
|
||||
keep_indices = nms(detections[:xyxy], detections[:confidence], threshold)
|
||||
|
||||
{
|
||||
xyxy: detections[:xyxy][keep_indices, true],
|
||||
confidence: detections[:confidence][keep_indices],
|
||||
class_id: detections[:class_id][keep_indices]
|
||||
}
|
||||
end
|
||||
|
||||
def model
|
||||
@model ||= OnnxRuntime::Model.new(
|
||||
MODEL_PATH.to_s,
|
||||
inter_op_num_threads: CPU_THREADS,
|
||||
intra_op_num_threads: CPU_THREADS,
|
||||
enable_mem_pattern: false,
|
||||
enable_cpu_mem_arena: false,
|
||||
providers: ['CPUExecutionProvider']
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -99,7 +99,7 @@ module Templates
|
||||
|
||||
max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1
|
||||
|
||||
generate_document_preview_images(attachment, data, (0..[number_of_pages - 1, max_pages_to_process].min))
|
||||
generate_document_preview_images(attachment, data, 0..[number_of_pages - 1, max_pages_to_process].min)
|
||||
end
|
||||
|
||||
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
module TextUtils
|
||||
RTL_REGEXP = /[\p{Hebrew}\p{Arabic}]/
|
||||
MASK_REGEXP = /[^\s\-_\[\]\(\)\+\?\.\,]/
|
||||
MASK_REGEXP = /[^\s\-_\[\]()+?.,]/
|
||||
MASK_SYMBOL = 'X'
|
||||
|
||||
TRANSLITERATIONS =
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ module TimeUtils
|
||||
DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
|
||||
DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
|
||||
|
||||
US_TIMEZONES = %w[EST CST MST PST HST AKDT].freeze
|
||||
US_TIMEZONES = %w[EST EDT CST CDT MST MDT PST PDT HST HDT AKST AKDT].freeze
|
||||
|
||||
module_function
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"canvas-confetti": "^1.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"compression-webpack-plugin": "10.0.0",
|
||||
"css-loader": "^6.7.3",
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user