create-devoplus-one-app 0.0.0-stage → 0.1.0-next.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +62 -2
  2. package/dist/cli.mjs +17 -0
  3. package/package.json +35 -3
  4. package/templates/0001_init.sql.tpl +10 -0
  5. package/templates/AGENTS.md.tpl +286 -0
  6. package/templates/AGENT_GUIDE.md.tpl +36 -0
  7. package/templates/README.md.tpl +58 -0
  8. package/templates/_shared/AGENTS.writes.md.tpl +38 -0
  9. package/templates/_shared/README.tail.md.tpl +20 -0
  10. package/templates/_shared/files/src/app.test.ts.tpl +36 -0
  11. package/templates/_shared/files/src/index.ts.tpl +8 -0
  12. package/templates/_shared/files/src/lib/client.ts.tpl +213 -0
  13. package/templates/_shared/files/src/lib/files.ts.tpl +37 -0
  14. package/templates/_shared/files/src/lib/http.ts.tpl +101 -0
  15. package/templates/_shared/files/src/lib/mail.ts.tpl +26 -0
  16. package/templates/_shared/files/src/lib/model.ts.tpl +31 -0
  17. package/templates/_shared/files/src/lib/page.ts.tpl +71 -0
  18. package/templates/_shared/files/src/lib/record.ts.tpl +246 -0
  19. package/templates/_shared/files/src/lib/server.ts.tpl +132 -0
  20. package/templates/_shared/files/src/shell.test.ts.tpl +56 -0
  21. package/templates/agent.ts.tpl +23 -0
  22. package/templates/bakim-takvimi/AGENTS.template.md.tpl +40 -0
  23. package/templates/bakim-takvimi/README.md.tpl +45 -0
  24. package/templates/bakim-takvimi/files/AGENT_GUIDE.md.tpl +52 -0
  25. package/templates/bakim-takvimi/files/one.data.json +45 -0
  26. package/templates/bakim-takvimi/files/src/agent.ts.tpl +22 -0
  27. package/templates/bakim-takvimi/files/src/app.ts.tpl +259 -0
  28. package/templates/bakim-takvimi/files/src/jobs.ts.tpl +170 -0
  29. package/templates/bakim-takvimi/files/src/names.ts.tpl +5 -0
  30. package/templates/bakim-takvimi/files/src/queries.ts.tpl +71 -0
  31. package/templates/bakim-takvimi/files/src/schedule.test.ts.tpl +58 -0
  32. package/templates/bakim-takvimi/files/src/schedule.ts.tpl +106 -0
  33. package/templates/bakim-takvimi/files/src/shell.ts.tpl +299 -0
  34. package/templates/bakim-takvimi/manifest.json.tpl +64 -0
  35. package/templates/egitim-sertifika/AGENTS.template.md.tpl +41 -0
  36. package/templates/egitim-sertifika/README.md.tpl +46 -0
  37. package/templates/egitim-sertifika/files/AGENT_GUIDE.md.tpl +53 -0
  38. package/templates/egitim-sertifika/files/one.data.json +43 -0
  39. package/templates/egitim-sertifika/files/src/agent.ts.tpl +22 -0
  40. package/templates/egitim-sertifika/files/src/app.ts.tpl +312 -0
  41. package/templates/egitim-sertifika/files/src/certificate.test.ts.tpl +83 -0
  42. package/templates/egitim-sertifika/files/src/certificate.ts.tpl +163 -0
  43. package/templates/egitim-sertifika/files/src/jobs.ts.tpl +219 -0
  44. package/templates/egitim-sertifika/files/src/names.ts.tpl +5 -0
  45. package/templates/egitim-sertifika/files/src/queries.ts.tpl +115 -0
  46. package/templates/egitim-sertifika/files/src/shell.ts.tpl +345 -0
  47. package/templates/egitim-sertifika/manifest.json.tpl +57 -0
  48. package/templates/envanter/AGENTS.template.md.tpl +28 -0
  49. package/templates/envanter/README.md.tpl +40 -0
  50. package/templates/envanter/files/AGENT_GUIDE.md.tpl +47 -0
  51. package/templates/envanter/files/one.data.json +62 -0
  52. package/templates/envanter/files/src/agent.ts.tpl +20 -0
  53. package/templates/envanter/files/src/app.ts.tpl +204 -0
  54. package/templates/envanter/files/src/jobs.ts.tpl +30 -0
  55. package/templates/envanter/files/src/queries.ts.tpl +53 -0
  56. package/templates/envanter/files/src/shell.ts.tpl +265 -0
  57. package/templates/envanter/files/src/stock.test.ts.tpl +31 -0
  58. package/templates/envanter/files/src/stock.ts.tpl +33 -0
  59. package/templates/envanter/manifest.json.tpl +56 -0
  60. package/templates/hedef-takibi/AGENTS.template.md.tpl +37 -0
  61. package/templates/hedef-takibi/README.md.tpl +52 -0
  62. package/templates/hedef-takibi/files/AGENT_GUIDE.md.tpl +48 -0
  63. package/templates/hedef-takibi/files/one.data.json +76 -0
  64. package/templates/hedef-takibi/files/src/agent.ts.tpl +26 -0
  65. package/templates/hedef-takibi/files/src/app.ts.tpl +740 -0
  66. package/templates/hedef-takibi/files/src/progress.test.ts.tpl +53 -0
  67. package/templates/hedef-takibi/files/src/progress.ts.tpl +81 -0
  68. package/templates/hedef-takibi/files/src/queries.ts.tpl +83 -0
  69. package/templates/hedef-takibi/files/src/shell.ts.tpl +455 -0
  70. package/templates/hedef-takibi/manifest.json.tpl +38 -0
  71. package/templates/index.ts.tpl +262 -0
  72. package/templates/markdown.d.ts.tpl +6 -0
  73. package/templates/musteri-ziyaret/AGENTS.template.md.tpl +47 -0
  74. package/templates/musteri-ziyaret/README.md.tpl +48 -0
  75. package/templates/musteri-ziyaret/files/AGENT_GUIDE.md.tpl +44 -0
  76. package/templates/musteri-ziyaret/files/one.data.json +53 -0
  77. package/templates/musteri-ziyaret/files/src/agent.ts.tpl +21 -0
  78. package/templates/musteri-ziyaret/files/src/app.ts.tpl +340 -0
  79. package/templates/musteri-ziyaret/files/src/customers.ts.tpl +85 -0
  80. package/templates/musteri-ziyaret/files/src/queries.ts.tpl +67 -0
  81. package/templates/musteri-ziyaret/files/src/shell.ts.tpl +272 -0
  82. package/templates/musteri-ziyaret/files/src/visit.test.ts.tpl +65 -0
  83. package/templates/musteri-ziyaret/files/src/visit.ts.tpl +98 -0
  84. package/templates/musteri-ziyaret/manifest.json.tpl +37 -0
  85. package/templates/saha-denetimi/AGENTS.template.md.tpl +32 -0
  86. package/templates/saha-denetimi/README.md.tpl +44 -0
  87. package/templates/saha-denetimi/files/AGENT_GUIDE.md.tpl +55 -0
  88. package/templates/saha-denetimi/files/one.data.json +87 -0
  89. package/templates/saha-denetimi/files/src/agent.ts.tpl +41 -0
  90. package/templates/saha-denetimi/files/src/app.ts.tpl +444 -0
  91. package/templates/saha-denetimi/files/src/queries.ts.tpl +143 -0
  92. package/templates/saha-denetimi/files/src/score.test.ts.tpl +48 -0
  93. package/templates/saha-denetimi/files/src/score.ts.tpl +102 -0
  94. package/templates/saha-denetimi/files/src/shell.ts.tpl +585 -0
  95. package/templates/saha-denetimi/manifest.json.tpl +49 -0
  96. package/templates/shell.test.ts.tpl +30 -0
  97. package/templates/shell.ts.tpl +201 -0
  98. package/templates/uygunsuzluk-dof/AGENTS.template.md.tpl +40 -0
  99. package/templates/uygunsuzluk-dof/README.md.tpl +46 -0
  100. package/templates/uygunsuzluk-dof/files/AGENT_GUIDE.md.tpl +56 -0
  101. package/templates/uygunsuzluk-dof/files/one.data.json +99 -0
  102. package/templates/uygunsuzluk-dof/files/src/agent.ts.tpl +45 -0
  103. package/templates/uygunsuzluk-dof/files/src/app.ts.tpl +431 -0
  104. package/templates/uygunsuzluk-dof/files/src/capa.test.ts.tpl +53 -0
  105. package/templates/uygunsuzluk-dof/files/src/capa.ts.tpl +114 -0
  106. package/templates/uygunsuzluk-dof/files/src/jobs.ts.tpl +127 -0
  107. package/templates/uygunsuzluk-dof/files/src/queries.ts.tpl +102 -0
  108. package/templates/uygunsuzluk-dof/files/src/shell.ts.tpl +441 -0
  109. package/templates/uygunsuzluk-dof/manifest.json.tpl +56 -0
  110. package/templates/ziyaretci-kaydi/AGENTS.template.md.tpl +35 -0
  111. package/templates/ziyaretci-kaydi/README.md.tpl +46 -0
  112. package/templates/ziyaretci-kaydi/files/AGENT_GUIDE.md.tpl +39 -0
  113. package/templates/ziyaretci-kaydi/files/one.data.json +42 -0
  114. package/templates/ziyaretci-kaydi/files/src/agent.ts.tpl +13 -0
  115. package/templates/ziyaretci-kaydi/files/src/app.ts.tpl +293 -0
  116. package/templates/ziyaretci-kaydi/files/src/jobs.ts.tpl +49 -0
  117. package/templates/ziyaretci-kaydi/files/src/notice.ts.tpl +48 -0
  118. package/templates/ziyaretci-kaydi/files/src/queries.ts.tpl +45 -0
  119. package/templates/ziyaretci-kaydi/files/src/shell.ts.tpl +252 -0
  120. package/templates/ziyaretci-kaydi/files/src/visitor.test.ts.tpl +34 -0
  121. package/templates/ziyaretci-kaydi/files/src/visitor.ts.tpl +57 -0
  122. package/templates/ziyaretci-kaydi/manifest.json.tpl +38 -0
@@ -0,0 +1,252 @@
1
+ // {{nameTr}} — sayfa (üç görünüm: Ziyaretçilerim · Resepsiyon · Tüm kayıtlar). Liste, süzgeç, form ve kişi seçici hazır
2
+ // bileşenlerdir; seçenekler veri modelinden gelir. Giriş ve çıkış zamanını sunucu yazar; sayfa yalnız eylemi ister.
3
+ import type { OneNavigationItem } from '@devoplus/one-sdk'
4
+ import { dataModel } from './data/types.ts'
5
+ import { renderClientScript } from './lib/client.ts'
6
+ import { choices, label } from './lib/model.ts'
7
+ import { escapeHtml, i18n, renderPage, translator, view, type Lang, type Strings, type Theme } from './lib/page.ts'
8
+
9
+ export const STRINGS: Strings = {
10
+ tr: {
11
+ title: {{nameTrJs}},
12
+ waiting: "Portal'dan oturum bekleniyor…",
13
+ networkError: 'İstek gönderilemedi (ağ hatası).',
14
+ requestFailed: 'İşlem yapılamadı.',
15
+ readOnly: 'Vekalet ya da Yerine Görüntüle oturumundasınız: bu uygulamada yalnız görüntüleme yapabilirsiniz.',
16
+ mineIntro: 'Sizi ziyarete gelen ve gelecek olan ziyaretçiler. Ziyaretçiniz giriş yaptığında size bildirim gelir.',
17
+ mineTable: 'Ziyaretçilerim',
18
+ receptionIntro: 'Bugün beklenen ziyaretçiler. Bir satırı seçip giriş ya da çıkışını yapın; zamanı sistem yazar.',
19
+ managersOnly: 'Bu bölüm yalnız resepsiyon görevlilerine açıktır.',
20
+ counts: 'Bugün: {expected} bekleniyor, {checkedIn} giriş yaptı, {left} ayrıldı. Şu an içeride: {inside}.',
21
+ registerForm: 'Yeni ziyaretçi',
22
+ registered: 'Ziyaretçi kaydedildi.',
23
+ host: 'Ziyaret edilen çalışan',
24
+ todayTable: 'Bugünkü ziyaretçiler',
25
+ selected: 'Seçili ziyaretçi',
26
+ selectHint: 'İşlem yapmak için listeden bir ziyaretçi seçin.',
27
+ checkIn: 'Giriş yap',
28
+ checkOut: 'Çıkış yap',
29
+ cancel: 'İptal et',
30
+ cancelConfirm: 'Bu ziyaret iptal edilsin mi?',
31
+ checkedIn: 'Giriş kaydedildi.',
32
+ checkedOut: 'Çıkış kaydedildi.',
33
+ cancelled: 'Ziyaret iptal edildi.',
34
+ hostNotified: 'Ziyaret edilen çalışana bildirim gönderildi.',
35
+ mailConsent: 'Giriş kaydedildi ama e-posta gönderilemedi: portaldaki "İzinleri incele" düğmesiyle e-posta izni verin.',
36
+ mailFailed: 'Giriş kaydedildi ama e-posta gönderilemedi.',
37
+ allIntro: 'Bütün ziyaret kayıtları. Ad soyad, firma ve ziyaret amacı {days} gün sonra kendiliğinden silinir.',
38
+ allTable: 'Ziyaret kayıtları',
39
+ search: 'Ara',
40
+ lastRetention: 'Son kişisel veri temizliği: {day} — {n} kaydın kişisel bilgileri silindi.',
41
+ noRetention: 'Henüz kişisel veri temizliği yapılmadı.',
42
+ anonymized: '(silindi)',
43
+ },
44
+ en: {
45
+ title: {{nameEnJs}},
46
+ waiting: 'Waiting for the session from the Portal…',
47
+ networkError: 'Request could not be sent (network error).',
48
+ requestFailed: 'The action could not be completed.',
49
+ readOnly: 'You are in a Delegation or View as User session: you can only view this app.',
50
+ mineIntro: 'Visitors who came or will come to see you. You are notified when your visitor checks in.',
51
+ mineTable: 'My visitors',
52
+ receptionIntro: 'Visitors expected today. Select a row to check them in or out; the system records the time.',
53
+ managersOnly: 'Only reception staff can use this section.',
54
+ counts: 'Today: {expected} expected, {checkedIn} checked in, {left} left. Inside now: {inside}.',
55
+ registerForm: 'New visitor',
56
+ registered: 'Visitor registered.',
57
+ host: 'Employee being visited',
58
+ todayTable: "Today's visitors",
59
+ selected: 'Selected visitor',
60
+ selectHint: 'Select a visitor from the list to take an action.',
61
+ checkIn: 'Check in',
62
+ checkOut: 'Check out',
63
+ cancel: 'Cancel',
64
+ cancelConfirm: 'Cancel this visit?',
65
+ checkedIn: 'Check-in recorded.',
66
+ checkedOut: 'Check-out recorded.',
67
+ cancelled: 'Visit cancelled.',
68
+ hostNotified: 'The employee being visited was notified.',
69
+ mailConsent: 'Check-in recorded, but no e-mail was sent: allow e-mail with the "Review permissions" button in the Portal.',
70
+ mailFailed: 'Check-in recorded, but the e-mail could not be sent.',
71
+ allIntro: 'All visit records. Full name, company and purpose of visit are deleted automatically after {days} days.',
72
+ allTable: 'Visit records',
73
+ search: 'Search',
74
+ lastRetention: 'Latest personal data clean-up: {day} — personal data of {n} records was deleted.',
75
+ noRetention: 'No personal data clean-up has run yet.',
76
+ anonymized: '(deleted)',
77
+ },
78
+ }
79
+
80
+ const MODEL = {
81
+ manage: '{{permissionPrefix}}Manage',
82
+ status: choices(dataModel, 'visitors', 'status'),
83
+ labels: {
84
+ fullName: label(dataModel, 'visitors', 'full_name'),
85
+ company: label(dataModel, 'visitors', 'company'),
86
+ purpose: label(dataModel, 'visitors', 'purpose'),
87
+ hostName: label(dataModel, 'visitors', 'host_name'),
88
+ plannedAt: label(dataModel, 'visitors', 'planned_at'),
89
+ status: label(dataModel, 'visitors', 'status'),
90
+ checkedInAt: label(dataModel, 'visitors', 'checked_in_at'),
91
+ checkedOutAt: label(dataModel, 'visitors', 'checked_out_at'),
92
+ registeredBy: label(dataModel, 'visitors', 'registered_by_name'),
93
+ },
94
+ }
95
+
96
+ export function renderShell(lang: Lang, theme: Theme, path: string): string {
97
+ const tx = translator(STRINGS, lang)
98
+ const btn = (id: string, key: string, cls = 'one-btn') => `<button type="button" class="${cls}" id="${id}" data-i18n="${key}" disabled>${escapeHtml(tx(key))}</button>`
99
+ const body = [
100
+ view(
101
+ '/',
102
+ path,
103
+ `${i18n(tx, 'mineIntro', 'p', 'class="one-muted"')}
104
+ <one-table id="mineTable" src="/api/mine/visitors" paging="page" export="xlsx csv" caption="${escapeHtml(tx('mineTable'))}"></one-table>`,
105
+ ),
106
+ view(
107
+ '/reception',
108
+ path,
109
+ `${i18n(tx, 'receptionIntro', 'p', 'class="one-muted"')}
110
+ ${i18n(tx, 'managersOnly', 'p', 'class="one-muted" id="receptionDenied"')}
111
+ <div id="receptionBox" hidden>
112
+ <p><strong id="countsBanner"></strong></p>
113
+ <section class="one-card">
114
+ <h2 data-i18n="registerForm">${escapeHtml(tx('registerForm'))}</h2>
115
+ <one-form id="visitorForm" action="/api/visitors" method="POST" no-cancel></one-form>
116
+ </section>
117
+ <one-table id="todayTable" src="/api/today/visitors" paging="page" export="xlsx csv" clickable caption="${escapeHtml(tx('todayTable'))}"></one-table>
118
+ <section class="one-card app-detail">
119
+ <h2 data-i18n="selected">${escapeHtml(tx('selected'))}</h2>
120
+ <p id="selectedName" class="one-muted" data-i18n="selectHint">${escapeHtml(tx('selectHint'))}</p>
121
+ <div class="app-toolbar">${btn('checkIn', 'checkIn', 'one-btn one-btn-primary')}${btn('checkOut', 'checkOut')}${btn('cancelVisit', 'cancel')}</div>
122
+ </section>
123
+ </div>`,
124
+ ),
125
+ view(
126
+ '/visitors',
127
+ path,
128
+ `${i18n(tx, 'managersOnly', 'p', 'class="one-muted" id="allDenied"')}
129
+ <div id="allBox" hidden>
130
+ <p class="one-muted" id="allIntro"></p>
131
+ <p class="one-muted" id="retentionNote"></p>
132
+ <one-filter id="allFilter" for="allTable"></one-filter>
133
+ <one-table id="allTable" src="/api/visitors" paging="page" export="xlsx csv" caption="${escapeHtml(tx('allTable'))}"></one-table>
134
+ </div>`,
135
+ ),
136
+ ].join('\n')
137
+ return renderPage({ lang, theme, title: tx('title'), body })
138
+ }
139
+
140
+ export function renderShellScript(navigation: readonly OneNavigationItem[], options: { actingWrites?: boolean } = {}): string {
141
+ return renderClientScript({ strings: STRINGS, navigation, ...(options.actingWrites !== undefined ? { actingWrites: options.actingWrites } : {}), body: pageScript() })
142
+ }
143
+
144
+ function pageScript(): string {
145
+ return `
146
+ var M = ${JSON.stringify(MODEL).replace(/<\//g, '<\\/')};
147
+ var mineTable = $('mineTable'), todayTable = $('todayTable'), allTable = $('allTable'), allFilter = $('allFilter'), form = $('visitorForm');
148
+ var selected = null, retentionDays = null, lastRetention = null;
149
+
150
+ // Saklama süresi dolan kaydın adı boştur; listede "(silindi)" yazar.
151
+ function personName(v, row) { return row && row.anonymized_at ? t('anonymized') : (v || ''); }
152
+ var common = [
153
+ { key: 'full_name', label: M.labels.fullName, format: personName },
154
+ { key: 'company', label: M.labels.company },
155
+ { key: 'purpose', label: M.labels.purpose }
156
+ ];
157
+ mineTable.columns = common.concat([
158
+ { key: 'planned_at', label: M.labels.plannedAt, type: 'datetime' },
159
+ { key: 'status', label: M.labels.status, type: 'choice', options: M.status },
160
+ { key: 'checked_in_at', label: M.labels.checkedInAt, type: 'datetime' },
161
+ { key: 'checked_out_at', label: M.labels.checkedOutAt, type: 'datetime' }
162
+ ]);
163
+ todayTable.columns = common.concat([
164
+ { key: 'host_name', label: M.labels.hostName },
165
+ { key: 'planned_at', label: M.labels.plannedAt, type: 'datetime' },
166
+ { key: 'status', label: M.labels.status, type: 'choice', options: M.status },
167
+ { key: 'checked_in_at', label: M.labels.checkedInAt, type: 'datetime' },
168
+ { key: 'checked_out_at', label: M.labels.checkedOutAt, type: 'datetime' }
169
+ ]);
170
+ allTable.columns = common.concat([
171
+ { key: 'host_name', label: M.labels.hostName },
172
+ { key: 'planned_at', label: M.labels.plannedAt, type: 'datetime' },
173
+ { key: 'status', label: M.labels.status, type: 'choice', options: M.status },
174
+ { key: 'checked_in_at', label: M.labels.checkedInAt, type: 'datetime' },
175
+ { key: 'checked_out_at', label: M.labels.checkedOutAt, type: 'datetime' },
176
+ { key: 'registered_by_name', label: M.labels.registeredBy }
177
+ ]);
178
+ allFilter.fields = [
179
+ { key: 'q', label: { tr: S.tr.search, en: S.en.search }, type: 'text' },
180
+ { key: 'status', label: M.labels.status, type: 'choice', options: M.status }
181
+ ];
182
+ form.fields = [
183
+ { key: 'full_name', label: M.labels.fullName, required: true, maxLength: 120 },
184
+ { key: 'company', label: M.labels.company, maxLength: 120 },
185
+ { key: 'purpose', label: M.labels.purpose, required: true, maxLength: 200 },
186
+ { key: 'host', label: { tr: S.tr.host, en: S.en.host }, type: 'person', required: true },
187
+ { key: 'planned_at', label: M.labels.plannedAt, type: 'datetime', required: true }
188
+ ];
189
+ function resetForm() { form.value = { planned_at: new Date().toISOString() }; }
190
+ resetForm();
191
+
192
+ function select(row) {
193
+ selected = row;
194
+ var name = $('selectedName');
195
+ if (!row) { name.setAttribute('data-i18n', 'selectHint'); name.textContent = t('selectHint'); }
196
+ else { name.removeAttribute('data-i18n'); name.textContent = personName(row.full_name, row) + (row.host_name ? ' → ' + row.host_name : ''); }
197
+ var ok = !!row && canWrite() && !row.anonymized_at;
198
+ $('checkIn').disabled = !(ok && row.status === 'expected');
199
+ $('cancelVisit').disabled = !(ok && row.status === 'expected');
200
+ $('checkOut').disabled = !(ok && row.status === 'inside');
201
+ }
202
+ function refresh() { todayTable.reload(); mineTable.reload(); if (!$('allBox').hidden) allTable.reload(); loadSummary(); select(null); }
203
+ function loadSummary() {
204
+ return api('GET', '/api/summary').then(function (r) {
205
+ if (!r.ok) return;
206
+ var c = r.body.counts;
207
+ $('countsBanner').textContent = t('counts', { expected: fmtNumber(c.expected), checkedIn: fmtNumber(c.checkedIn), left: fmtNumber(c.left), inside: fmtNumber(c.insideNow) });
208
+ retentionDays = r.body.retentionDays; lastRetention = r.body.lastRetention;
209
+ renderRetention();
210
+ });
211
+ }
212
+ function renderRetention() {
213
+ if (retentionDays == null) return;
214
+ $('allIntro').textContent = t('allIntro', { days: fmtNumber(retentionDays) });
215
+ $('retentionNote').textContent = lastRetention ? t('lastRetention', { day: fmtDate(lastRetention.run_key), n: fmtNumber(lastRetention.cleared_count) }) : t('noRetention');
216
+ }
217
+ function notifyResult(n) {
218
+ if (!n) return;
219
+ if (n.mail === 'sent' || n.push === 'sent') one.toast({ severity: 'success', summary: t('hostNotified') });
220
+ if (n.mail === 'consent-needed') one.toast({ severity: 'warn', summary: t('mailConsent') });
221
+ else if (n.mail === 'failed') one.toast({ severity: 'warn', summary: t('mailFailed') });
222
+ }
223
+ function act(action, okKey) {
224
+ if (!selected) return;
225
+ api('POST', '/api/visitors/' + encodeURIComponent(selected.id) + '/' + action).then(function (r) {
226
+ if (!r.ok) { one.toast({ severity: 'error', summary: problem(r) }); refresh(); return; }
227
+ one.toast({ severity: 'success', summary: t(okKey) });
228
+ notifyResult(r.body.notification);
229
+ refresh();
230
+ });
231
+ }
232
+ todayTable.addEventListener('rowclick', function (e) { select(e.detail.row); });
233
+ $('checkIn').addEventListener('click', function () { act('check-in', 'checkedIn'); });
234
+ $('checkOut').addEventListener('click', function () { act('check-out', 'checkedOut'); });
235
+ $('cancelVisit').addEventListener('click', function () {
236
+ one.confirm({ message: t('cancelConfirm'), severity: 'warn' }).then(function (yes) { if (yes) act('cancel', 'cancelled'); });
237
+ });
238
+ form.addEventListener('saved', function (e) {
239
+ e.preventDefault();
240
+ one.toast({ severity: 'success', summary: t('registered') });
241
+ resetForm(); refresh();
242
+ });
243
+ onSession(function () {
244
+ var manager = can(M.manage);
245
+ $('receptionBox').toggleAttribute('hidden', !manager); $('receptionDenied').toggleAttribute('hidden', manager);
246
+ $('allBox').toggleAttribute('hidden', !manager); $('allDenied').toggleAttribute('hidden', manager);
247
+ if (manager) { todayTable.reload(); allTable.reload(); }
248
+ });
249
+ onLang(function () { loadSummary(); select(selected); });
250
+ onReady(function () { loadSummary(); select(null); });
251
+ `
252
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { PLAN_AHEAD_MAX_DAYS, RETENTION_DAYS, TRANSITIONS, checkPlannedAt, istanbulDay, retentionCutoff, transitionConflict } from './visitor.ts'
3
+
4
+ const NOW = new Date('2026-10-20T09:00:00Z')
5
+ const DAY = 86_400_000
6
+
7
+ describe('ziyaretçi kuralları', () => {
8
+ it('ziyaret günü İstanbul saatine göre', () => {
9
+ expect(istanbulDay(new Date('2026-10-20T20:59:00Z'))).toBe('2026-10-20')
10
+ expect(istanbulDay(new Date('2026-10-20T21:00:00Z'))).toBe('2026-10-21')
11
+ })
12
+
13
+ it('saklama sınırı: şu andan RETENTION_DAYS gün önce', () => {
14
+ expect(RETENTION_DAYS).toBeGreaterThan(0)
15
+ expect(retentionCutoff(NOW)).toBe(new Date(NOW.getTime() - RETENTION_DAYS * DAY).toISOString())
16
+ expect(retentionCutoff(NOW, 30)).toBe('2026-09-20T09:00:00.000Z')
17
+ })
18
+
19
+ it('planlanan zaman: saat dilimli; saklama süresinden eski ya da çok ileri olamaz', () => {
20
+ expect(checkPlannedAt('2026-10-20T14:00:00+03:00', NOW)).toEqual({ ok: true, iso: '2026-10-20T11:00:00.000Z', day: '2026-10-20' })
21
+ expect(checkPlannedAt('2026-10-20T14:00:00', NOW)).toMatchObject({ ok: false })
22
+ expect(checkPlannedAt('yarın', NOW)).toMatchObject({ ok: false })
23
+ expect(checkPlannedAt(new Date(NOW.getTime() - (RETENTION_DAYS + 1) * DAY).toISOString(), NOW)).toMatchObject({ ok: false })
24
+ expect(checkPlannedAt(new Date(NOW.getTime() + (PLAN_AHEAD_MAX_DAYS + 1) * DAY).toISOString(), NOW)).toMatchObject({ ok: false })
25
+ })
26
+
27
+ it('durum geçişleri: giriş yalnız beklenenden, çıkış yalnız içeridekinden', () => {
28
+ expect(TRANSITIONS['check-in']).toEqual({ from: 'expected', to: 'inside' })
29
+ expect(TRANSITIONS['check-out']).toEqual({ from: 'inside', to: 'left' })
30
+ expect(TRANSITIONS.cancel).toEqual({ from: 'expected', to: 'cancelled' })
31
+ expect(transitionConflict('check-in', 'inside')).toContain('zaten')
32
+ expect(transitionConflict('check-out', 'expected')).toContain('henüz')
33
+ })
34
+ })
@@ -0,0 +1,57 @@
1
+ // Ziyaretçi kuralları — SAF fonksiyonlar (veritabanı yok): saklama süresi, ziyaret günü, planlanan zaman doğrulaması ve
2
+ // durum geçişleri. Sunucu her işlemi burada doğrular; kuralı değiştiren bu dosyayı ve `visitor.test.ts`'i birlikte
3
+ // değiştirir.
4
+
5
+ /**
6
+ * ⛔ KVKK saklama süresi (gün): ziyaretin son olayından (çıkış, yoksa giriş, yoksa planlanan zaman) bu kadar gün sonra
7
+ * ziyaretçinin kişisel bilgileri (ad soyad, firma, ziyaret amacı) gece işiyle silinir. TEK yer burasıdır; değiştirirseniz
8
+ * README ve AGENTS.md'deki süreyi de güncelleyin. Süreyi kurumunuzun kişisel veri saklama politikası belirler.
9
+ */
10
+ export const RETENTION_DAYS = 90
11
+ /** En çok kaç gün sonrası için ziyaret planlanabilir. */
12
+ export const PLAN_AHEAD_MAX_DAYS = 365
13
+ const DAY_MS = 86_400_000
14
+
15
+ export const STATUSES = ['expected', 'inside', 'left', 'cancelled'] as const
16
+ export type VisitStatus = (typeof STATUSES)[number]
17
+ export type VisitAction = 'check-in' | 'check-out' | 'cancel'
18
+
19
+ /** Eylem → hangi durumdan hangi duruma (başka durumdaki kayıtta eylem yapılmaz: 409). */
20
+ export const TRANSITIONS: Record<VisitAction, { from: VisitStatus; to: VisitStatus }> = {
21
+ 'check-in': { from: 'expected', to: 'inside' },
22
+ 'check-out': { from: 'inside', to: 'left' },
23
+ cancel: { from: 'expected', to: 'cancelled' },
24
+ }
25
+
26
+ /** Ziyaret günü: İstanbul saatiyle gün (YYYY-AA-GG). Türkiye'de yaz saati uygulaması yoktur (UTC+3). */
27
+ export function istanbulDay(at: Date): string {
28
+ return new Date(at.getTime() + 3 * 3_600_000).toISOString().slice(0, 10)
29
+ }
30
+
31
+ /** Saklama süresinin sınırı: son olayı bu andan ESKİ olan ziyaretlerin kişisel bilgileri silinir. */
32
+ export function retentionCutoff(now: Date, days = RETENTION_DAYS): string {
33
+ return new Date(now.getTime() - days * DAY_MS).toISOString()
34
+ }
35
+
36
+ const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/
37
+
38
+ export type PlannedCheck = { ok: true; iso: string; day: string } | { ok: false; message: string }
39
+
40
+ /** Planlanan zaman: saat dilimli ISO tarih-saat; saklama süresinden eski ya da bir yıldan ileri olamaz. */
41
+ export function checkPlannedAt(raw: unknown, now: Date): PlannedCheck {
42
+ if (typeof raw !== 'string' || !DATETIME_RE.test(raw.trim())) return { ok: false, message: 'Planlanan zamanı tarih ve saat olarak girin.' }
43
+ const t = Date.parse(raw.trim())
44
+ if (!Number.isFinite(t)) return { ok: false, message: 'Planlanan zamanı tarih ve saat olarak girin.' }
45
+ if (t < now.getTime() - RETENTION_DAYS * DAY_MS) return { ok: false, message: `Planlanan zaman ${RETENTION_DAYS} günden eski olamaz.` }
46
+ if (t > now.getTime() + PLAN_AHEAD_MAX_DAYS * DAY_MS) return { ok: false, message: `Ziyaret en çok ${PLAN_AHEAD_MAX_DAYS} gün sonrası için planlanabilir.` }
47
+ const at = new Date(t)
48
+ return { ok: true, iso: at.toISOString(), day: istanbulDay(at) }
49
+ }
50
+
51
+ /** Eylem yapılamadığında kullanıcıya söylenecek neden (kaydın bugünkü durumuna göre). */
52
+ export function transitionConflict(action: VisitAction, current: string): string {
53
+ if (current === 'inside') return action === 'check-in' ? 'Ziyaretçinin girişi zaten yapıldı.' : 'Ziyaretçi içeride; önce çıkışını yapın.'
54
+ if (current === 'left') return 'Ziyaretçi çıkış yaptı; bu kayıtta işlem yapılamaz.'
55
+ if (current === 'cancelled') return 'Ziyaret iptal edildi; bu kayıtta işlem yapılamaz.'
56
+ return 'Ziyaretçinin girişi henüz yapılmadı.'
57
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "scopes": ["orgschema.read", "mail.execute", "push.execute"],
3
+ "services": { "database": true, "files": false, "cache": false },
4
+ "menuIcon": "pi pi-id-card",
5
+ "navigation": [
6
+ { "path": "/", "label": { "tr": "Ziyaretçilerim", "en": "My visitors" } },
7
+ { "path": "/reception", "label": { "tr": "Resepsiyon", "en": "Reception" }, "permission": "{{permissionPrefix}}Manage" },
8
+ { "path": "/visitors", "label": { "tr": "Tüm kayıtlar", "en": "All records" }, "permission": "{{permissionPrefix}}Manage" }
9
+ ],
10
+ "permissions": [
11
+ { "code": "{{permissionPrefix}}Manage", "name": { "tr": "Ziyaretçi kaydı tut (resepsiyon)", "en": "Keep the visitor log (reception)" } }
12
+ ],
13
+ "agent": {
14
+ "summary": {
15
+ "tr": "Ziyaretçi kaydı uygulaması: bugün beklenen, içeride olan ve ayrılan ziyaretçilerin sayıları (kişisel bilgi vermez).",
16
+ "en": "Visitor log app: counts of visitors expected today, inside now and left today (no personal data)."
17
+ },
18
+ "tools": [
19
+ {
20
+ "name": "count-visitors-today",
21
+ "title": { "tr": "Bugünkü ziyaretçi sayıları", "en": "Today's visitor counts" },
22
+ "description": {
23
+ "tr": "Bugün beklenen, şu an içeride olan ve bugün ayrılan ziyaretçilerin SAYILARINI döndürür; ad, firma ya da amaç döndürmez. Salt okunur.",
24
+ "en": "Returns the NUMBERS of visitors expected today, inside now and left today; never names, companies or purposes. Read-only."
25
+ },
26
+ "readOnly": true,
27
+ "permission": "{{permissionCode}}",
28
+ "input": { "type": "object", "properties": {} }
29
+ }
30
+ ]
31
+ },
32
+ "jobs": {
33
+ "schedules": [
34
+ { "name": "visitor-data-retention", "cron": "30 2 * * *", "timeZone": "Europe/Istanbul", "description": "Saklama süresi dolan ziyaretçilerin kişisel bilgilerini siler" }
35
+ ],
36
+ "queues": []
37
+ }
38
+ }