Publication initiale de RelaisSMS : documentation et sources nettoyees
This commit is contained in:
@@ -0,0 +1,899 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../views/**/*.blade.php';
|
||||
|
||||
@theme {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f4f7fb;
|
||||
--panel: #ffffff;
|
||||
--ink: #172033;
|
||||
--muted: #687184;
|
||||
--line: #dde4ef;
|
||||
--soft: #eef3f8;
|
||||
--brand: #156f73;
|
||||
--brand-dark: #0d5558;
|
||||
--accent: #f1b544;
|
||||
--danger: #bf3d3d;
|
||||
--ok: #2f8756;
|
||||
--shadow: 0 18px 40px rgba(31, 49, 78, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
background: #102a35;
|
||||
color: #e9f4f5;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 760;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-brand-mark {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #102a35;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-nav-link,
|
||||
.app-nav-button {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #c7dadd;
|
||||
padding: 0 12px;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-nav-link:hover,
|
||||
.app-nav-button:hover,
|
||||
.app-nav-link.active {
|
||||
background: #1b4655;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.app-nav-ico {
|
||||
width: 22px;
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sync-card {
|
||||
margin-top: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.sync-card strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sync-card span {
|
||||
display: block;
|
||||
color: #b8ccd0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sync-logout {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
display: grid;
|
||||
max-width: 1100px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-wrap.wide {
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.page-wrap.narrow {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title h1,
|
||||
.page-title {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.title p,
|
||||
.page-subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel.flush {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.composer-panel {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
|
||||
.panel-header h2,
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stack-sm {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 22rem;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-grid.five {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats-grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--ink);
|
||||
font-size: 27px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-grid.cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.form-grid.cols-3 {
|
||||
grid-template-columns: minmax(0, 1fr) 14rem auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-grid.search-grid {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-grid.filters-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-grid.contact-filters-grid {
|
||||
grid-template-columns: minmax(14rem, 1.4fr) minmax(11rem, 1fr) minmax(11rem, 1fr) minmax(10rem, 0.8fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field label,
|
||||
.field-label {
|
||||
color: #374053;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.input,
|
||||
.select,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.textarea.compact {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(21, 111, 115, 0.16);
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
padding: 0 14px;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand);
|
||||
color: #ffffff;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: var(--brand-dark);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #efb1b1;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
background: #fff1f1;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--soft);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tabs span,
|
||||
.tabs a {
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs span.active,
|
||||
.tabs a.active {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.badge,
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
background: #e7f3f0;
|
||||
color: var(--ok);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 0 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.pending,
|
||||
.pill.warn {
|
||||
background: #fff1d2;
|
||||
color: #76530b;
|
||||
}
|
||||
|
||||
.badge.error,
|
||||
.pill.error {
|
||||
background: #ffe8e8;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.message-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.check-option {
|
||||
display: inline-flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
color: #374053;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.message-preview strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #374053;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-preview pre {
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.favorite-row {
|
||||
border-color: #f1d38a;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.favorite-star {
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recipient-composer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recipient-chipbox {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.recipient-chipbox:focus-within {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(21, 111, 115, 0.16);
|
||||
}
|
||||
|
||||
.recipient-chip {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e7f3f0;
|
||||
color: #164f4d;
|
||||
font-size: 13px;
|
||||
font-weight: 670;
|
||||
padding: 0 8px 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipient-chip.group {
|
||||
background: #fff1d2;
|
||||
color: #6c4b05;
|
||||
}
|
||||
|
||||
.recipient-chip.favorite {
|
||||
box-shadow: inset 0 0 0 1px #f1b544;
|
||||
}
|
||||
|
||||
.recipient-chip span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recipient-chip button {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.recipient-input {
|
||||
min-width: 220px;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.recipient-results {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
right: 0;
|
||||
left: 0;
|
||||
margin-top: 6px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.recipient-result {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #ffffff;
|
||||
color: var(--ink);
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.recipient-result:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.recipient-result:hover,
|
||||
.recipient-result.active {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.recipient-result strong,
|
||||
.recipient-result span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipient-result small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.final-recipient-row {
|
||||
grid-template-columns: minmax(0, 1fr) 10rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flex-fill {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.h-offset {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.mt-sm {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.nowrap-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
display: block;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.item-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-strong {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.text-section {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
white-space: pre-line;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.audit-term {
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.audit-value {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 13px 16px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #fbfcfe;
|
||||
color: var(--muted);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.list-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 34px 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
border: 1px solid #b8ded7;
|
||||
border-radius: 8px;
|
||||
background: #e7f3f0;
|
||||
color: #164f4d;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
border-color: #efb1b1;
|
||||
background: #ffe8e8;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sync-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-grid,
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats-grid.five,
|
||||
.stats-grid.three,
|
||||
.form-grid.contact-filters-grid,
|
||||
.form-grid.filters-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.app-main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-nav,
|
||||
.form-grid.cols-2,
|
||||
.form-grid.cols-3,
|
||||
.form-grid.search-grid,
|
||||
.form-grid.contact-filters-grid,
|
||||
.form-grid.filters-grid,
|
||||
.stats-grid.five,
|
||||
.stats-grid.three,
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
min-width: 620px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
const debounce = (callback, delay = 180) => {
|
||||
let timeout;
|
||||
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => callback(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
const createRecipientChip = (candidate) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = [
|
||||
'recipient-chip',
|
||||
candidate.type === 'group' ? 'group' : '',
|
||||
candidate.is_favorite ? 'favorite' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
chip.dataset.recipientToken = '';
|
||||
chip.dataset.type = candidate.type;
|
||||
chip.dataset.id = String(candidate.id);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = candidate.label;
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.dataset.recipientRemove = '';
|
||||
remove.setAttribute('aria-label', `Retirer ${candidate.label}`);
|
||||
remove.textContent = '×';
|
||||
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = candidate.type === 'group' ? 'group_ids[]' : 'contact_ids[]';
|
||||
hidden.value = String(candidate.id);
|
||||
|
||||
chip.append(label, remove, hidden);
|
||||
|
||||
return chip;
|
||||
};
|
||||
|
||||
const initRecipientComposer = (composer) => {
|
||||
const searchUrl = composer.dataset.searchUrl;
|
||||
const selected = composer.querySelector('[data-recipient-selected]');
|
||||
const input = composer.querySelector('[data-recipient-input]');
|
||||
const results = composer.querySelector('[data-recipient-results]');
|
||||
|
||||
if (!searchUrl || !selected || !input || !results) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedKeys = new Set(
|
||||
[...selected.querySelectorAll('[data-recipient-token]')].map((chip) => `${chip.dataset.type}:${chip.dataset.id}`),
|
||||
);
|
||||
let candidates = [];
|
||||
let activeIndex = -1;
|
||||
|
||||
const hideResults = () => {
|
||||
results.hidden = true;
|
||||
results.replaceChildren();
|
||||
activeIndex = -1;
|
||||
};
|
||||
|
||||
const addCandidate = (candidate) => {
|
||||
const key = `${candidate.type}:${candidate.id}`;
|
||||
|
||||
if (selectedKeys.has(key)) {
|
||||
input.value = '';
|
||||
hideResults();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedKeys.add(key);
|
||||
selected.insertBefore(createRecipientChip(candidate), input);
|
||||
input.value = '';
|
||||
hideResults();
|
||||
input.focus();
|
||||
};
|
||||
|
||||
const renderResults = () => {
|
||||
results.replaceChildren();
|
||||
|
||||
if (candidates.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'recipient-result';
|
||||
empty.textContent = 'Aucun resultat';
|
||||
results.append(empty);
|
||||
results.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
candidates.forEach((candidate, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `recipient-result${index === activeIndex ? ' active' : ''}`;
|
||||
button.dataset.index = String(index);
|
||||
|
||||
const main = document.createElement('span');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = `${candidate.is_favorite ? '★ ' : ''}${candidate.label}`;
|
||||
const meta = document.createElement('small');
|
||||
meta.textContent = candidate.meta || candidate.source || '';
|
||||
main.append(title, document.createElement('br'), meta);
|
||||
|
||||
const type = document.createElement('small');
|
||||
type.textContent = candidate.type === 'group' ? 'Groupe' : 'Contact';
|
||||
|
||||
button.append(main, type);
|
||||
button.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
addCandidate(candidate);
|
||||
});
|
||||
results.append(button);
|
||||
});
|
||||
|
||||
results.hidden = false;
|
||||
};
|
||||
|
||||
const fitResultsToViewport = () => {
|
||||
const rect = results.getBoundingClientRect();
|
||||
const availableHeight = window.innerHeight - rect.top - 16;
|
||||
results.style.maxHeight = `${Math.max(180, availableHeight)}px`;
|
||||
};
|
||||
|
||||
const search = debounce(async () => {
|
||||
const query = input.value.trim();
|
||||
const url = new URL(searchUrl, window.location.origin);
|
||||
url.searchParams.set('q', query);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
hideResults();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
candidates = (payload.results || []).filter(
|
||||
(candidate) => !selectedKeys.has(`${candidate.type}:${candidate.id}`),
|
||||
);
|
||||
activeIndex = candidates.length > 0 ? 0 : -1;
|
||||
renderResults();
|
||||
fitResultsToViewport();
|
||||
});
|
||||
|
||||
input.addEventListener('input', search);
|
||||
input.addEventListener('focus', search);
|
||||
window.addEventListener('resize', fitResultsToViewport);
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
hideResults();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && input.value === '') {
|
||||
const chip = input.previousElementSibling;
|
||||
if (chip?.matches('[data-recipient-token]')) {
|
||||
selectedKeys.delete(`${chip.dataset.type}:${chip.dataset.id}`);
|
||||
chip.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.hidden || candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % candidates.length;
|
||||
renderResults();
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + candidates.length) % candidates.length;
|
||||
renderResults();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
addCandidate(candidates[activeIndex]);
|
||||
}
|
||||
});
|
||||
|
||||
selected.addEventListener('click', (event) => {
|
||||
const remove = event.target.closest('[data-recipient-remove]');
|
||||
|
||||
if (!remove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chip = remove.closest('[data-recipient-token]');
|
||||
selectedKeys.delete(`${chip.dataset.type}:${chip.dataset.id}`);
|
||||
chip.remove();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!composer.contains(event.target)) {
|
||||
hideResults();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-recipient-composer]').forEach(initRecipientComposer);
|
||||
|
||||
const initScheduleControls = (toggle) => {
|
||||
const form = toggle.closest('form');
|
||||
const panel = form?.querySelector('[data-schedule-panel]');
|
||||
const input = form?.querySelector('[data-schedule-input]');
|
||||
const submit = form?.querySelector('[data-schedule-submit]');
|
||||
|
||||
if (!form || !panel || !input || !submit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showPanel = () => {
|
||||
panel.hidden = false;
|
||||
submit.hidden = false;
|
||||
input.required = true;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
input.focus();
|
||||
};
|
||||
|
||||
if (!panel.hidden) {
|
||||
input.required = true;
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', showPanel);
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-schedule-toggle]').forEach(initScheduleControls);
|
||||
|
||||
const initMessageComposer = (composer) => {
|
||||
const form = composer.closest('form');
|
||||
const body = form?.querySelector('[data-message-body]');
|
||||
const includeHeader = composer.querySelector('[data-message-include-header]');
|
||||
const includeFooter = composer.querySelector('[data-message-include-footer]');
|
||||
const preview = composer.querySelector('[data-message-preview]');
|
||||
const header = composer.dataset.header || '';
|
||||
const footer = composer.dataset.footer || '';
|
||||
|
||||
if (!form || !body || !preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
const parts = [];
|
||||
|
||||
if (includeHeader?.checked && header.trim() !== '') {
|
||||
parts.push(header.trim());
|
||||
}
|
||||
|
||||
if (body.value.trim() !== '') {
|
||||
parts.push(body.value.trim());
|
||||
}
|
||||
|
||||
if (includeFooter?.checked && footer.trim() !== '') {
|
||||
parts.push(footer.trim());
|
||||
}
|
||||
|
||||
preview.textContent = parts.join('\n');
|
||||
};
|
||||
|
||||
body.addEventListener('input', render);
|
||||
includeHeader?.addEventListener('change', render);
|
||||
includeFooter?.addEventListener('change', render);
|
||||
render();
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-message-composer]').forEach(initMessageComposer);
|
||||
@@ -0,0 +1,54 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm">
|
||||
<h1 class="text-2xl font-semibold">Connexion</h1>
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="mt-6 space-y-5">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-zinc-700">Identifiant</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
value="{{ old('email') }}"
|
||||
autocomplete="username"
|
||||
required
|
||||
autofocus
|
||||
class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20"
|
||||
>
|
||||
@error('email')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-zinc-700">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20"
|
||||
>
|
||||
@error('password')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input name="remember" type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600">
|
||||
Rester connecte
|
||||
</label>
|
||||
|
||||
<button type="submit" class="w-full rounded-md bg-teal-700 px-4 py-2.5 text-sm font-semibold text-white hover:bg-teal-800 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2">
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,112 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<section class="page-wrap">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<a href="{{ route('contacts.index', ['group' => $group->id]) }}" class="link">Retour aux contacts</a>
|
||||
<h1 class="h-offset">Modifier le groupe</h1>
|
||||
<p>{{ $group->contacts_count }} contact(s) · groupe local</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="POST" action="{{ route('contact-groups.update', $group) }}" class="panel">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="members_submitted" value="1">
|
||||
|
||||
<div class="panel-header">
|
||||
<h2>Informations</h2>
|
||||
</div>
|
||||
|
||||
<div class="panel-body stack">
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label for="name">Nom</label>
|
||||
<input id="name" name="name" value="{{ old('name', $group->name) }}" required class="input">
|
||||
@error('name')
|
||||
<p class="notice error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<label class="field-label inline-badges">
|
||||
<input name="is_favorite" type="checkbox" value="1" @checked(old('is_favorite', $group->is_favorite))>
|
||||
Favori
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="4" class="textarea">{{ old('description', $group->description) }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-header">
|
||||
<h2>Membres</h2>
|
||||
<span class="pill">{{ count($selectedContactIds) }} selectionne(s)</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Inclure</th>
|
||||
<th>Contact</th>
|
||||
<th>Telephone</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($contacts as $contact)
|
||||
@php($primaryPhone = $contact->phoneNumbers->firstWhere('is_primary', true) ?? $contact->phoneNumbers->first())
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
name="contact_ids[]"
|
||||
type="checkbox"
|
||||
value="{{ $contact->id }}"
|
||||
@checked(in_array($contact->id, old('contact_ids', $selectedContactIds), false))
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
{{ $contact->displayName() }}
|
||||
<div class="muted truncate">{{ $contact->email ?: $contact->organization ?: 'Sans detail' }}</div>
|
||||
</td>
|
||||
<td>{{ $primaryPhone?->normalized_number ?: $primaryPhone?->raw_number ?: 'Aucun' }}</td>
|
||||
<td>
|
||||
<span class="badge {{ $contact->source === 'local' ? '' : 'pending' }}">
|
||||
{{ ucfirst($contact->source) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">Aucun contact disponible.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary">Enregistrer</button>
|
||||
<a href="{{ route('contacts.index', ['group' => $group->id]) }}" class="secondary">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('contact-groups.destroy', $group) }}" onsubmit="return confirm('Supprimer ce groupe ?')" class="panel">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
|
||||
<div class="panel-header">
|
||||
<h2>Suppression</h2>
|
||||
</div>
|
||||
<div class="panel-body stack-sm">
|
||||
<p class="muted">La suppression retire le groupe des contacts associes.</p>
|
||||
<button type="submit" class="danger">Supprimer le groupe</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,113 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<section class="mx-auto max-w-3xl">
|
||||
<div class="border-b border-zinc-200 pb-6">
|
||||
<a href="{{ route('contacts.index') }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">Retour aux contacts</a>
|
||||
<h1 class="mt-3 text-2xl font-semibold">Nouveau contact</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('contacts.store') }}" class="mt-8 space-y-8">
|
||||
@csrf
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Identite</h2>
|
||||
|
||||
<div class="mt-5 grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-zinc-700">Prenom</label>
|
||||
<input id="first_name" name="first_name" value="{{ old('first_name') }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('first_name')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-zinc-700">Nom</label>
|
||||
<input id="last_name" name="last_name" value="{{ old('last_name') }}" required class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('last_name')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="organization" class="block text-sm font-medium text-zinc-700">Structure</label>
|
||||
<input id="organization" name="organization" value="{{ old('organization') }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-zinc-700">Email</label>
|
||||
<input id="email" name="email" type="email" value="{{ old('email') }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('email')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<label for="notes" class="block text-sm font-medium text-zinc-700">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">{{ old('notes') }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Telephone</h2>
|
||||
|
||||
<div class="mt-5 grid gap-5 sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<div>
|
||||
<label for="phone_label" class="block text-sm font-medium text-zinc-700">Libelle</label>
|
||||
<input id="phone_label" name="phone_label" value="{{ old('phone_label', 'Mobile') }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="raw_phone_number" class="block text-sm font-medium text-zinc-700">Numero</label>
|
||||
<input id="raw_phone_number" name="raw_phone_number" value="{{ old('raw_phone_number') }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('raw_phone_number')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Groupes et options</h2>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
@forelse ($groups as $group)
|
||||
<label class="flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
|
||||
<input
|
||||
name="group_ids[]"
|
||||
type="checkbox"
|
||||
value="{{ $group->id }}"
|
||||
@checked(in_array($group->id, old('group_ids', [])))
|
||||
class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600"
|
||||
>
|
||||
{{ $group->name }}
|
||||
</label>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-600">Aucun groupe disponible.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input name="sms_opt_out" type="checkbox" value="1" class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600">
|
||||
Refus SMS
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input name="is_favorite" type="checkbox" value="1" class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600">
|
||||
Favori
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="{{ route('contacts.index') }}" class="rounded-md border border-zinc-300 px-4 py-2.5 text-sm font-semibold text-zinc-800 hover:bg-zinc-100">Annuler</a>
|
||||
<button type="submit" class="rounded-md bg-teal-700 px-4 py-2.5 text-sm font-semibold text-white hover:bg-teal-800 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2">
|
||||
Creer le contact
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,127 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<section class="mx-auto max-w-3xl">
|
||||
<div class="border-b border-zinc-200 pb-6">
|
||||
<a href="{{ route('contacts.show', $contact) }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">Retour au contact</a>
|
||||
<h1 class="mt-3 text-2xl font-semibold">Modifier le contact</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('contacts.update', $contact) }}" class="mt-8 space-y-8">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Identite</h2>
|
||||
|
||||
<div class="mt-5 grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-zinc-700">Prenom</label>
|
||||
<input id="first_name" name="first_name" value="{{ old('first_name', $contact->first_name) }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('first_name')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-zinc-700">Nom</label>
|
||||
<input id="last_name" name="last_name" value="{{ old('last_name', $contact->last_name) }}" required class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('last_name')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="organization" class="block text-sm font-medium text-zinc-700">Structure</label>
|
||||
<input id="organization" name="organization" value="{{ old('organization', $contact->organization) }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-zinc-700">Email</label>
|
||||
<input id="email" name="email" type="email" value="{{ old('email', $contact->email) }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('email')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<label for="notes" class="block text-sm font-medium text-zinc-700">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="4" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">{{ old('notes', $contact->notes) }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Telephone principal</h2>
|
||||
|
||||
<div class="mt-5 grid gap-5 sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<div>
|
||||
<label for="phone_label" class="block text-sm font-medium text-zinc-700">Libelle</label>
|
||||
<input id="phone_label" name="phone_label" value="{{ old('phone_label', $primaryPhone?->label ?? 'Mobile') }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="raw_phone_number" class="block text-sm font-medium text-zinc-700">Numero</label>
|
||||
<input id="raw_phone_number" name="raw_phone_number" value="{{ old('raw_phone_number', $primaryPhone?->raw_number) }}" class="mt-2 block w-full rounded-md border border-zinc-300 px-3 py-2 text-base shadow-sm outline-none focus:border-teal-600 focus:ring-2 focus:ring-teal-600/20">
|
||||
@error('raw_phone_number')
|
||||
<p class="mt-2 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Groupes et options</h2>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
@php($selectedGroups = old('group_ids', $contact->groups->pluck('id')->all()))
|
||||
|
||||
@forelse ($groups as $group)
|
||||
<label class="flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
|
||||
<input
|
||||
name="group_ids[]"
|
||||
type="checkbox"
|
||||
value="{{ $group->id }}"
|
||||
@checked(in_array($group->id, $selectedGroups))
|
||||
class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600"
|
||||
>
|
||||
{{ $group->name }}
|
||||
</label>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-600">Aucun groupe disponible.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input name="sms_opt_out" type="checkbox" value="1" @checked(old('sms_opt_out', $contact->sms_opt_out)) class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600">
|
||||
Refus SMS
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input name="is_favorite" type="checkbox" value="1" @checked(old('is_favorite', $contact->is_favorite)) class="h-4 w-4 rounded border-zinc-300 text-teal-700 focus:ring-teal-600">
|
||||
Favori
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<button type="submit" form="delete-contact-form" class="rounded-md border border-red-300 px-4 py-2.5 text-sm font-semibold text-red-700 hover:bg-red-50">
|
||||
Supprimer
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('contacts.show', $contact) }}" class="rounded-md border border-zinc-300 px-4 py-2.5 text-sm font-semibold text-zinc-800 hover:bg-zinc-100">Annuler</a>
|
||||
<button type="submit" class="rounded-md bg-teal-700 px-4 py-2.5 text-sm font-semibold text-white hover:bg-teal-800 focus:outline-none focus:ring-2 focus:ring-teal-600 focus:ring-offset-2">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="delete-contact-form" method="POST" action="{{ route('contacts.destroy', $contact) }}" onsubmit="return confirm('Supprimer ce contact ?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,264 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$baseFilters = array_filter([
|
||||
'q' => $search,
|
||||
'group' => $selectedGroupId ?: null,
|
||||
'mobile' => $selectedMobileFilter,
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
@endphp
|
||||
|
||||
<section class="page-wrap wide">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Contacts</h1>
|
||||
<p>
|
||||
Contacts locaux, groupes et recherche.
|
||||
@if ($lastCardDavSyncRun)
|
||||
<span class="block sm:inline">CardDAV : {{ $lastCardDavSyncRun->status }} · {{ $lastCardDavSyncRun->started_at?->diffForHumans() }}</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@if ($cardDavSyncEnabled)
|
||||
<form method="POST" action="{{ route('contacts.sync-carddav') }}">
|
||||
@csrf
|
||||
<button type="submit" class="secondary">
|
||||
↻ Synchroniser
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('contacts.create') }}" class="primary">
|
||||
+ Contact local
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="stack">
|
||||
<form method="GET" action="{{ route('contacts.index') }}" class="panel flush">
|
||||
<div class="panel-body">
|
||||
<div class="form-grid contact-filters-grid">
|
||||
<div class="field">
|
||||
<label for="q">Recherche</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder="Nom, email ou telephone"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="group">Groupe</label>
|
||||
<select
|
||||
id="group"
|
||||
name="group"
|
||||
class="select"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
@foreach ($groups as $group)
|
||||
<option value="{{ $group->id }}" @selected($selectedGroupId === $group->id)>{{ $group->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="mobile">Mobile</label>
|
||||
<select id="mobile" name="mobile" class="select">
|
||||
<option value="detected" @selected($selectedMobileFilter === 'detected')>Mobile detecte</option>
|
||||
<option value="all" @selected($selectedMobileFilter === 'all')>Tous</option>
|
||||
<option value="non_mobile" @selected($selectedMobileFilter === 'non_mobile')>Non mobile</option>
|
||||
<option value="invalid" @selected($selectedMobileFilter === 'invalid')>Numero invalide</option>
|
||||
<option value="missing" @selected($selectedMobileFilter === 'missing')>Sans telephone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="status">Statut</label>
|
||||
<select id="status" name="status" class="select">
|
||||
<option value="" @selected($selectedStatus === '')>Tous</option>
|
||||
<option value="active" @selected($selectedStatus === 'active')>Actif</option>
|
||||
<option value="favorite" @selected($selectedStatus === 'favorite')>Favori</option>
|
||||
<option value="opt_out" @selected($selectedStatus === 'opt_out')>Refus SMS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="secondary">Filtrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Liste des contacts</h2>
|
||||
<div class="tabs" aria-label="Vue contacts">
|
||||
<a class="{{ (! $favoriteOnly && $selectedStatus === '') ? 'active' : '' }}" href="{{ route('contacts.index', $baseFilters) }}">Tous</a>
|
||||
<a class="{{ ($favoriteOnly || $selectedStatus === 'favorite') ? 'active' : '' }}" href="{{ route('contacts.index', array_merge($baseFilters, ['status' => 'favorite'])) }}">Favoris</a>
|
||||
<a class="{{ $selectedStatus === 'opt_out' ? 'active' : '' }}" href="{{ route('contacts.index', array_merge($baseFilters, ['status' => 'opt_out'])) }}">Refus SMS</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<th>Telephone</th>
|
||||
<th>Groupes</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($contacts as $contact)
|
||||
@php($primaryPhone = $contact->phoneNumbers->firstWhere('is_primary', true) ?? $contact->phoneNumbers->first())
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ route('contacts.show', $contact) }}" class="link item-line">
|
||||
@if ($contact->is_favorite)
|
||||
<span class="favorite-star" aria-label="Favori">★</span>
|
||||
@endif
|
||||
<span class="truncate">{{ $contact->displayName() }}</span>
|
||||
</a>
|
||||
<div class="muted truncate">{{ $contact->email ?: $contact->organization ?: ucfirst($contact->source) }}</div>
|
||||
</td>
|
||||
<td>
|
||||
@if ($primaryPhone)
|
||||
{{ $primaryPhone->normalized_number ?: $primaryPhone->raw_number }}
|
||||
@if (! $primaryPhone->is_valid)
|
||||
<div class="muted">Numero invalide</div>
|
||||
@elseif (! $primaryPhone->hasDetectedMobileNumber())
|
||||
<div class="muted">Non mobile</div>
|
||||
@else
|
||||
<div class="muted">Mobile detecte</div>
|
||||
@endif
|
||||
@else
|
||||
<span class="muted">Aucun</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $contact->groups_count }}</td>
|
||||
<td>
|
||||
<span class="inline-badges">
|
||||
@if ($contact->is_favorite)
|
||||
<span class="badge pending">Favori</span>
|
||||
@endif
|
||||
@if ($contact->sms_opt_out)
|
||||
<span class="badge error">Refus SMS</span>
|
||||
@endif
|
||||
@if (! $contact->is_favorite && ! $contact->sms_opt_out)
|
||||
<span class="badge">Actif</span>
|
||||
@endif
|
||||
</span>
|
||||
<form method="POST" action="{{ route('contacts.favorite', $contact) }}" class="mt-sm">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="is_favorite" value="{{ $contact->is_favorite ? '0' : '1' }}">
|
||||
<button type="submit" class="secondary">
|
||||
{{ $contact->is_favorite ? 'Retirer favori' : 'Favori' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('contacts.opt-out', $contact) }}" class="mt-sm">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="sms_opt_out" value="{{ $contact->sms_opt_out ? '0' : '1' }}">
|
||||
<button type="submit" class="secondary">
|
||||
{{ $contact->sms_opt_out ? 'Retirer Refus SMS' : 'Refus SMS' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">Aucun contact trouve.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ $contacts->links() }}
|
||||
</div>
|
||||
|
||||
<aside class="stack">
|
||||
<form method="POST" action="{{ route('contact-groups.store') }}" class="panel">
|
||||
@csrf
|
||||
|
||||
<div class="panel-header">
|
||||
<h2>Nouveau groupe</h2>
|
||||
</div>
|
||||
|
||||
<div class="panel-body stack-sm">
|
||||
<div class="field">
|
||||
<label for="name">Nom</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value="{{ old('name') }}"
|
||||
required
|
||||
class="input"
|
||||
>
|
||||
@error('name')
|
||||
<p class="notice error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
class="textarea"
|
||||
>{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<label class="field-label inline-badges">
|
||||
<input name="is_favorite" type="checkbox" value="1">
|
||||
Favori
|
||||
</label>
|
||||
|
||||
<button type="submit" class="primary">
|
||||
Creer le groupe
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Groupes</h2>
|
||||
</div>
|
||||
|
||||
<div class="panel-body stack-sm">
|
||||
@forelse ($groups as $group)
|
||||
<div class="summary-row {{ $group->is_favorite ? 'favorite-row' : '' }}">
|
||||
<a href="{{ route('contacts.index', ['group' => $group->id]) }}" class="link truncate flex-fill">
|
||||
@if ($group->is_favorite)
|
||||
<span class="favorite-star" aria-label="Favori">★</span>
|
||||
@endif
|
||||
{{ $group->name }}
|
||||
</a>
|
||||
<span class="pill">{{ $group->contacts_count }}</span>
|
||||
|
||||
@if ($group->source === 'local')
|
||||
<a href="{{ route('contact-groups.edit', $group) }}" class="secondary">
|
||||
Modifier
|
||||
</a>
|
||||
@else
|
||||
<span class="pill warn">{{ ucfirst($group->source) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="muted">Aucun groupe.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,151 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<section class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="border-b border-zinc-200 pb-6">
|
||||
<a href="{{ route('contacts.index') }}" class="text-sm font-medium text-teal-700 hover:text-teal-900">Retour aux contacts</a>
|
||||
<div class="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{{ $contact->displayName() }}</h1>
|
||||
<p class="mt-2 text-sm text-zinc-600">{{ $contact->organization ?: ucfirst($contact->source) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:items-end">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($contact->is_favorite)
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-sm font-semibold text-amber-800">Favori</span>
|
||||
@endif
|
||||
@if ($contact->sms_opt_out)
|
||||
<span class="rounded-full bg-red-50 px-3 py-1 text-sm font-semibold text-red-700">Refus SMS</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('contacts.favorite', $contact) }}" class="flex justify-end">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="is_favorite" value="{{ $contact->is_favorite ? '0' : '1' }}">
|
||||
<button type="submit" class="rounded-md border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-50">
|
||||
{{ $contact->is_favorite ? 'Retirer des favoris' : 'Mettre en favori' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('contacts.opt-out', $contact) }}" class="flex justify-end">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<input type="hidden" name="sms_opt_out" value="{{ $contact->sms_opt_out ? '0' : '1' }}">
|
||||
<button type="submit" class="rounded-md border border-red-300 px-4 py-2 text-sm font-semibold text-red-700 hover:bg-red-50">
|
||||
{{ $contact->sms_opt_out ? 'Retirer Refus SMS' : 'Refus SMS' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($contact->source === 'local')
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('contacts.edit', $contact) }}" class="rounded-md bg-teal-700 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-800">
|
||||
Modifier
|
||||
</a>
|
||||
|
||||
<form method="POST" action="{{ route('contacts.destroy', $contact) }}" onsubmit="return confirm('Supprimer ce contact ?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-md border border-red-300 px-4 py-2 text-sm font-semibold text-red-700 hover:bg-red-50">
|
||||
Supprimer
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-sm font-semibold text-amber-800">
|
||||
Fiche importee non modifiable
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="rounded-md border border-teal-200 bg-teal-50 px-4 py-3 text-sm font-medium text-teal-900">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Coordonnees</h2>
|
||||
|
||||
<dl class="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-zinc-500">Email</dt>
|
||||
<dd class="mt-1 text-sm text-zinc-900">{{ $contact->email ?: 'Non renseigne' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-zinc-500">Source</dt>
|
||||
<dd class="mt-1 text-sm text-zinc-900">{{ $contact->source }}</dd>
|
||||
</div>
|
||||
@if ($contact->synced_at)
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-zinc-500">Derniere synchro</dt>
|
||||
<dd class="mt-1 text-sm text-zinc-900">{{ $contact->synced_at->format('d/m/Y H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Numeros</h2>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
@forelse ($contact->phoneNumbers as $phoneNumber)
|
||||
<div class="rounded-md border border-zinc-200 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-zinc-900">{{ $phoneNumber->label ?: 'Telephone' }}</p>
|
||||
<p class="mt-1 text-sm text-zinc-700">{{ $phoneNumber->normalized_number ?: $phoneNumber->raw_number }}</p>
|
||||
@if ($phoneNumber->normalized_number)
|
||||
<p class="mt-1 text-xs text-zinc-500">Brut : {{ $phoneNumber->raw_number }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $phoneNumber->is_valid ? 'bg-teal-50 text-teal-800' : 'bg-red-50 text-red-700' }}">
|
||||
{{ $phoneNumber->is_valid ? 'Valide' : 'Invalide' }}
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $phoneNumber->hasDetectedMobileNumber() ? 'bg-teal-50 text-teal-800' : 'bg-zinc-100 text-zinc-700' }}">
|
||||
{{ $phoneNumber->hasDetectedMobileNumber() ? 'Mobile' : 'Non mobile' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($phoneNumber->validation_error)
|
||||
<p class="mt-3 text-sm text-red-700">{{ $phoneNumber->validation_error }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-600">Aucun numero.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($contact->notes)
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Notes</h2>
|
||||
<p class="mt-4 whitespace-pre-line text-sm text-zinc-700">{{ $contact->notes }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<aside class="rounded-lg border border-zinc-200 bg-white p-5">
|
||||
<h2 class="text-base font-semibold">Groupes</h2>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
@forelse ($contact->groups as $group)
|
||||
<a href="{{ route('contacts.index', ['group' => $group->id]) }}" class="block rounded-md border border-zinc-200 px-3 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
|
||||
{{ $group->name }}
|
||||
</a>
|
||||
@empty
|
||||
<p class="text-sm text-zinc-600">Aucun groupe.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'RelaisSMS') }}</title>
|
||||
|
||||
@if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot')))
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@endif
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
@auth
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" aria-label="Navigation principale">
|
||||
<a href="{{ route('recipients.index') }}" class="app-brand">
|
||||
<span class="app-brand-mark">R</span>
|
||||
<span>RelaisSMS</span>
|
||||
</a>
|
||||
|
||||
<nav class="app-nav">
|
||||
<a href="{{ route('recipients.index') }}" class="app-nav-link {{ request()->routeIs('recipients.*') ? 'active' : '' }}">
|
||||
<span class="app-nav-ico">✉</span>
|
||||
<span>Envoi</span>
|
||||
</a>
|
||||
<a href="{{ route('contacts.index') }}" class="app-nav-link {{ request()->routeIs('contacts.*') ? 'active' : '' }}">
|
||||
<span class="app-nav-ico">⌕</span>
|
||||
<span>Contacts</span>
|
||||
</a>
|
||||
<a href="{{ route('contacts.index') }}" class="app-nav-link {{ request()->routeIs('contact-groups.*') ? 'active' : '' }}">
|
||||
<span class="app-nav-ico">▦</span>
|
||||
<span>Groupes</span>
|
||||
</a>
|
||||
<a href="{{ route('sms-campaigns.index') }}" class="app-nav-link {{ request()->routeIs('sms-campaigns.*') ? 'active' : '' }}">
|
||||
<span class="app-nav-ico">◷</span>
|
||||
<span>Historique</span>
|
||||
</a>
|
||||
<a href="{{ route('settings.edit') }}" class="app-nav-link {{ request()->routeIs('settings.*') ? 'active' : '' }}">
|
||||
<span class="app-nav-ico">⚙</span>
|
||||
<span>Parametres</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sync-card">
|
||||
<strong>Session active</strong>
|
||||
<span>{{ auth()->user()->name }}</span>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="app-nav-button sync-logout">
|
||||
<span class="app-nav-ico">↩</span>
|
||||
<span>Deconnexion</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="app-main">
|
||||
@if (session('status'))
|
||||
<div class="notice flash">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="notice error flash">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
</div>
|
||||
@else
|
||||
<main class="app-main">
|
||||
@if (session('status'))
|
||||
<div class="notice flash">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<div class="notice error flash">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</main>
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,292 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$reasonLabels = [
|
||||
'opt_out' => 'Refus SMS',
|
||||
'missing_phone' => 'Sans telephone',
|
||||
'invalid' => 'Numero invalide',
|
||||
'non_mobile' => 'Non mobile',
|
||||
'duplicate' => 'Doublon',
|
||||
];
|
||||
$summary = $resolution?->summary();
|
||||
$showSchedulePanel = old('send_mode', request('send_mode')) === 'scheduled' || old('scheduled_at', request('scheduled_at'));
|
||||
@endphp
|
||||
|
||||
<section class="page-wrap">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Composer</h1>
|
||||
<p>Selectionner les destinataires et saisir le SMS.</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="{{ route('contacts.index') }}" class="secondary">
|
||||
Contacts
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@error('selection')
|
||||
<div class="notice error">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
|
||||
<form method="POST" action="{{ route('recipients.preview') }}" class="stack">
|
||||
@csrf
|
||||
<input type="hidden" name="q" value="{{ $search }}">
|
||||
|
||||
<section class="panel composer-panel" data-recipient-composer data-search-url="{{ route('recipients.search') }}">
|
||||
<div class="panel-header">
|
||||
<h2>Destination</h2>
|
||||
</div>
|
||||
|
||||
<div class="panel-body stack">
|
||||
<div class="field">
|
||||
<label for="recipient-composer-input">Pour</label>
|
||||
<div class="recipient-composer">
|
||||
<div class="recipient-chipbox" data-recipient-selected>
|
||||
@foreach ($selectedGroups as $group)
|
||||
<span class="recipient-chip group {{ $group->is_favorite ? 'favorite' : '' }}" data-recipient-token data-type="group" data-id="{{ $group->id }}">
|
||||
<span>{{ $group->name }}</span>
|
||||
<button type="button" data-recipient-remove aria-label="Retirer {{ $group->name }}">×</button>
|
||||
<input type="hidden" name="group_ids[]" value="{{ $group->id }}">
|
||||
</span>
|
||||
@endforeach
|
||||
|
||||
@foreach ($selectedContacts as $contact)
|
||||
<span class="recipient-chip {{ $contact->is_favorite ? 'favorite' : '' }}" data-recipient-token data-type="contact" data-id="{{ $contact->id }}">
|
||||
<span>{{ $contact->displayName() }}</span>
|
||||
<button type="button" data-recipient-remove aria-label="Retirer {{ $contact->displayName() }}">×</button>
|
||||
<input type="hidden" name="contact_ids[]" value="{{ $contact->id }}">
|
||||
</span>
|
||||
@endforeach
|
||||
|
||||
<input
|
||||
id="recipient-composer-input"
|
||||
type="search"
|
||||
class="recipient-input"
|
||||
autocomplete="off"
|
||||
placeholder="Ajouter un contact ou un groupe..."
|
||||
data-recipient-input
|
||||
>
|
||||
</div>
|
||||
<div class="recipient-results" data-recipient-results hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="message">Message SMS</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows="5"
|
||||
maxlength="612"
|
||||
required
|
||||
class="textarea"
|
||||
data-message-body
|
||||
>{{ old('message', request('message')) }}</textarea>
|
||||
@error('message')
|
||||
<p class="notice error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@if ($smsHeader !== '' || $smsFooter !== '')
|
||||
<div
|
||||
class="stack-sm"
|
||||
data-message-composer
|
||||
data-header="{{ e($smsHeader) }}"
|
||||
data-footer="{{ e($smsFooter) }}"
|
||||
>
|
||||
<div class="message-options">
|
||||
@if ($smsHeader !== '')
|
||||
<input type="hidden" name="include_sms_header" value="0">
|
||||
<label class="check-option">
|
||||
<input
|
||||
name="include_sms_header"
|
||||
type="checkbox"
|
||||
value="1"
|
||||
@checked(old('include_sms_header', $includeSmsHeader ? '1' : '0'))
|
||||
data-message-include-header
|
||||
>
|
||||
Entete
|
||||
</label>
|
||||
@endif
|
||||
|
||||
@if ($smsFooter !== '')
|
||||
<input type="hidden" name="include_sms_footer" value="0">
|
||||
<label class="check-option">
|
||||
<input
|
||||
name="include_sms_footer"
|
||||
type="checkbox"
|
||||
value="1"
|
||||
@checked(old('include_sms_footer', $includeSmsFooter ? '1' : '0'))
|
||||
data-message-include-footer
|
||||
>
|
||||
Pied
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="message-preview">
|
||||
<strong>SMS complet</strong>
|
||||
<pre data-message-preview>{{ $composedMessage }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="form-grid cols-2" data-schedule-panel @unless ($showSchedulePanel) hidden @endunless>
|
||||
<div class="field">
|
||||
<label for="scheduled_at">Date et heure d'envoi</label>
|
||||
<input
|
||||
id="scheduled_at"
|
||||
type="datetime-local"
|
||||
name="scheduled_at"
|
||||
value="{{ old('scheduled_at', request('scheduled_at')) }}"
|
||||
class="input"
|
||||
data-schedule-input
|
||||
>
|
||||
@error('scheduled_at')
|
||||
<p class="notice error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary" formnovalidate>
|
||||
Previsualiser
|
||||
</button>
|
||||
@if ($resolution && $resolution->recipients !== [])
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
name="send_mode"
|
||||
value="now"
|
||||
formaction="{{ route('sms-campaigns.store') }}"
|
||||
onclick="return confirm('Envoyer ce SMS a {{ count($resolution->recipients) }} numero(s) ?')"
|
||||
>
|
||||
Envoyer {{ count($resolution->recipients) }} SMS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
data-schedule-toggle
|
||||
aria-expanded="{{ $showSchedulePanel ? 'true' : 'false' }}"
|
||||
>
|
||||
Programmer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="secondary"
|
||||
name="send_mode"
|
||||
value="scheduled"
|
||||
formaction="{{ route('sms-campaigns.store') }}"
|
||||
data-schedule-submit
|
||||
@unless ($showSchedulePanel) hidden @endunless
|
||||
onclick="return confirm('Programmer ce SMS pour {{ count($resolution->recipients) }} numero(s) ?')"
|
||||
>
|
||||
Confirmer la programmation
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($resolution)
|
||||
<div class="summary-row" aria-label="Resume avant envoi">
|
||||
<span class="pill">{{ count($resolution->recipients) }} numeros valides</span>
|
||||
<span class="pill">1 SMS / destinataire</span>
|
||||
<span class="pill warn">{{ $summary['excluded_total'] }} exclus</span>
|
||||
<span class="pill warn">{{ $summary['duplicate'] }} doublons</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
@if ($resolution)
|
||||
<div class="stack">
|
||||
<div class="stats-grid five">
|
||||
<div class="stat-card">
|
||||
<span>Numeros prets</span>
|
||||
<strong>{{ $summary['ready_numbers'] }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Contacts</span>
|
||||
<strong>{{ $summary['selected_contacts'] }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Groupes</span>
|
||||
<strong>{{ $summary['selected_groups'] }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Exclus</span>
|
||||
<strong>{{ $summary['excluded_total'] }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Doublons</span>
|
||||
<strong>{{ $summary['duplicate'] }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Liste finale</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@forelse ($resolution->recipients as $recipient)
|
||||
<div class="list-row final-recipient-row">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-strong">{{ $recipient['contact_name'] }}</p>
|
||||
<p class="muted truncate text-small">{{ implode(' · ', $recipient['selection']) }}</p>
|
||||
</div>
|
||||
<p class="text-small text-strong">{{ $recipient['number'] }}</p>
|
||||
</div>
|
||||
@empty
|
||||
<div class="empty-state">Aucun numero pret.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Ecarts</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@foreach ($reasonLabels as $reason => $label)
|
||||
@php($entries = $resolution->excludedByReason($reason))
|
||||
@if ($entries !== [])
|
||||
<div class="list-row">
|
||||
<p class="text-section">{{ $label }} · {{ count($entries) }}</p>
|
||||
<div class="stack-sm">
|
||||
@foreach ($entries as $entry)
|
||||
<div class="text-small">
|
||||
<p class="text-strong">{{ $entry['contact_name'] }}</p>
|
||||
<p class="muted">
|
||||
{{ ucfirst($entry['contact_source'] ?? 'local') }} #{{ $entry['contact_id'] }}
|
||||
</p>
|
||||
<p class="muted">
|
||||
{{ $entry['number'] ?: $entry['raw_number'] ?: 'Aucun numero' }}
|
||||
@if (($entry['duplicate_of'] ?? null) !== null)
|
||||
· deja retenu pour {{ $entry['duplicate_of'] }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if ($resolution->excluded === [])
|
||||
<div class="empty-state">Aucun ecart.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,57 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<section class="page-wrap narrow">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Parametres</h1>
|
||||
<p>Textes ajoutes par defaut autour des SMS.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="POST" action="{{ route('settings.update') }}" class="panel">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="panel-header">
|
||||
<h2>Composition SMS</h2>
|
||||
</div>
|
||||
|
||||
<div class="panel-body stack">
|
||||
<div class="field">
|
||||
<label for="sms_message_header">Entete de message</label>
|
||||
<textarea
|
||||
id="sms_message_header"
|
||||
name="sms_message_header"
|
||||
rows="3"
|
||||
maxlength="612"
|
||||
class="textarea compact"
|
||||
>{{ old('sms_message_header', $smsHeader) }}</textarea>
|
||||
@error('sms_message_header')
|
||||
<p class="notice error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sms_message_footer">Pied de message</label>
|
||||
<textarea
|
||||
id="sms_message_footer"
|
||||
name="sms_message_footer"
|
||||
rows="3"
|
||||
maxlength="612"
|
||||
class="textarea compact"
|
||||
>{{ old('sms_message_footer', $smsFooter) }}</textarea>
|
||||
@error('sms_message_footer')
|
||||
<p class="notice error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,129 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<section class="page-wrap wide">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Historique SMS</h1>
|
||||
<p>Recherche, audit et export des campagnes SMS.</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="{{ route('sms-campaigns.export', $filters) }}" class="secondary">
|
||||
Export CSV
|
||||
</a>
|
||||
<a href="{{ route('recipients.index') }}" class="primary">
|
||||
Nouvel envoi
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="GET" action="{{ route('sms-campaigns.index') }}" class="panel flush">
|
||||
<div class="panel-body">
|
||||
<div class="form-grid filters-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">Du</span>
|
||||
<input type="date" name="from" value="{{ $filters['from'] ?? '' }}" class="input">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Au</span>
|
||||
<input type="date" name="to" value="{{ $filters['to'] ?? '' }}" class="input">
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Statut</span>
|
||||
<select name="status" class="select">
|
||||
<option value="">Tous</option>
|
||||
@foreach ($statuses as $status => $label)
|
||||
<option value="{{ $status }}" @selected(($filters['status'] ?? '') === $status)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Utilisateur</span>
|
||||
<select name="user_id" class="select">
|
||||
<option value="">Tous</option>
|
||||
@foreach ($users as $user)
|
||||
<option value="{{ $user->id }}" @selected((string) ($filters['user_id'] ?? '') === (string) $user->id)>{{ $user->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Groupe</span>
|
||||
<select name="group_id" class="select">
|
||||
<option value="">Tous</option>
|
||||
@foreach ($groups as $group)
|
||||
<option value="{{ $group->id }}" @selected((string) ($filters['group_id'] ?? '') === (string) $group->id)>{{ $group->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="actions nowrap-actions">
|
||||
<button type="submit" class="primary">
|
||||
Filtrer
|
||||
</button>
|
||||
<a href="{{ route('sms-campaigns.index') }}" class="secondary">
|
||||
Reset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Campagnes</h2>
|
||||
<div class="tabs" aria-label="Statut">
|
||||
<span class="active">Toutes</span>
|
||||
<span>Envoyees</span>
|
||||
<span>Erreurs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Programmation</th>
|
||||
<th>Message</th>
|
||||
<th>Destinataires</th>
|
||||
<th>Groupes</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($campaigns as $campaign)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ route('sms-campaigns.show', $campaign) }}" class="link">{{ $campaign->created_at->format('d/m/Y H:i') }}</a>
|
||||
<div class="muted">{{ $campaign->user?->name ?: 'Utilisateur supprime' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ $campaign->scheduled_at?->timezone(config('relais_sms.default_settings.display_timezone', 'Europe/Paris'))->format('d/m/Y H:i') ?: '-' }}
|
||||
</td>
|
||||
<td>{{ $campaign->message }}</td>
|
||||
<td>{{ $campaign->sent_count }}/{{ $campaign->recipients_count }} envoye(s)</td>
|
||||
<td class="truncate">{{ implode(', ', $campaign->selected_group_names ?? []) ?: 'Aucun groupe' }}</td>
|
||||
<td>
|
||||
<span class="badge {{ in_array($campaign->status, ['failed', 'partial', 'canceled'], true) ? 'error' : (in_array($campaign->status, ['scheduled', 'uncertain'], true) ? 'pending' : '') }}">
|
||||
{{ $statuses[$campaign->status] ?? $campaign->status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="empty-state">Aucune campagne ne correspond aux filtres.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ $campaigns->links() }}
|
||||
</section>
|
||||
@endsection
|
||||
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$displayTimezone = config('relais_sms.default_settings.display_timezone', 'Europe/Paris');
|
||||
$statusLabels = [
|
||||
'draft' => 'Brouillon',
|
||||
'scheduled' => 'Programme',
|
||||
'sending' => 'Envoi',
|
||||
'uncertain' => 'A verifier',
|
||||
'sent' => 'Envoye',
|
||||
'partial' => 'Partiel',
|
||||
'failed' => 'Echec',
|
||||
'canceled' => 'Annule',
|
||||
'queued' => 'En attente',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section class="page-wrap">
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<a href="{{ route('sms-campaigns.index') }}" class="link">Retour a l'historique</a>
|
||||
<h1 class="h-offset">Campagne #{{ $campaign->id }}</h1>
|
||||
<p>{{ $campaign->created_at->format('d/m/Y H:i') }} par {{ $campaign->user?->name ?: 'Utilisateur supprime' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@if ($campaign->status === 'scheduled')
|
||||
<form method="POST" action="{{ route('sms-campaigns.cancel', $campaign) }}">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button
|
||||
type="submit"
|
||||
class="secondary"
|
||||
onclick="return confirm('Annuler cette campagne programmee ?')"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
<span class="badge {{ in_array($campaign->status, ['failed', 'partial', 'canceled'], true) ? 'error' : (in_array($campaign->status, ['scheduled', 'uncertain'], true) ? 'pending' : '') }}">
|
||||
{{ $statusLabels[$campaign->status] ?? $campaign->status }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid three">
|
||||
<div class="stat-card">
|
||||
<span>Destinataires</span>
|
||||
<strong>{{ $campaign->recipients_count }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Envoyes</span>
|
||||
<strong>{{ $campaign->sent_count }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Echecs</span>
|
||||
<strong>{{ $campaign->failed_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($campaign->status === 'uncertain')
|
||||
<p class="notice">{{ $campaign->recipients_count - $campaign->sent_count - $campaign->failed_count }} destinataire(s) a verifier. Un delai d attente ne signifie pas que les SMS ont echoue. La verification automatique consulte OVH sans renvoyer de SMS. Ne pas renvoyer cette campagne.</p>
|
||||
@endif
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Message</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="message-text">{{ $campaign->message }}</p>
|
||||
|
||||
@if ($campaign->error_message)
|
||||
<p class="notice {{ $campaign->status === 'uncertain' ? '' : 'error' }} mt-md">{{ $campaign->error_message }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Audit</h2>
|
||||
</div>
|
||||
<dl class="panel-body form-grid cols-2 audit-list">
|
||||
<div>
|
||||
<dt class="audit-term">Utilisateur</dt>
|
||||
<dd class="audit-value">{{ $campaign->user?->name ?: 'Utilisateur supprime' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="audit-term">Cree le</dt>
|
||||
<dd class="audit-value">{{ $campaign->created_at->format('d/m/Y H:i') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="audit-term">Programme le</dt>
|
||||
<dd class="audit-value">{{ $campaign->scheduled_at?->timezone($displayTimezone)->format('d/m/Y H:i') ?: 'Non programme' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="audit-term">Annule le</dt>
|
||||
<dd class="audit-value">{{ $campaign->canceled_at?->timezone($displayTimezone)->format('d/m/Y H:i') ?: 'Non annule' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="audit-term">Groupes selectionnes</dt>
|
||||
<dd class="audit-value">{{ implode(', ', $campaign->selected_group_names ?? []) ?: 'Aucun groupe' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Destinataires</h2>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<th>Numero</th>
|
||||
<th>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($campaign->deliveries as $delivery)
|
||||
<tr>
|
||||
<td>
|
||||
{{ $delivery->contact_name }}
|
||||
<div class="muted truncate">{{ $delivery->provider_message_id ?: $delivery->error_message }}</div>
|
||||
</td>
|
||||
<td>{{ $delivery->number }}</td>
|
||||
<td>
|
||||
<span class="badge {{ in_array($delivery->status, ['failed', 'canceled'], true) ? 'error' : (in_array($delivery->status, ['queued', 'uncertain'], true) ? 'pending' : '') }}">
|
||||
{{ $statusLabels[$delivery->status] ?? $delivery->status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user