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,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { addDays, checkMaintenanceDate, checkPeriod, daysBetween, dueStatus, groupByResponsible, isCalendarDate, needsReminder, nextDueDate, todayKey, type DueRow } from './schedule.ts'
3
+
4
+ describe('bakım takvimi kuralları', () => {
5
+ it('sıradaki bakım = son bakım + aralık; ay ve yıl geçişleri doğru', () => {
6
+ expect(nextDueDate('2026-01-31', 30)).toBe('2026-03-02')
7
+ expect(nextDueDate('2028-02-28', 1)).toBe('2028-02-29') // artık yıl
8
+ expect(nextDueDate('2026-12-15', 30)).toBe('2027-01-14')
9
+ expect(addDays('2026-03-01', -1)).toBe('2026-02-28')
10
+ expect(daysBetween('2026-10-20', '2026-10-27')).toBe(7)
11
+ expect(daysBetween('2026-10-20', '2026-10-18')).toBe(-2)
12
+ })
13
+
14
+ it('bakım aralığı tam gün ve sınırlar içinde', () => {
15
+ expect(checkPeriod(30)).toEqual({ ok: true, value: 30 })
16
+ expect(checkPeriod('90')).toEqual({ ok: true, value: 90 })
17
+ expect(checkPeriod(1.5)).toMatchObject({ ok: false })
18
+ expect(checkPeriod(0)).toMatchObject({ ok: false })
19
+ expect(checkPeriod(3651)).toMatchObject({ ok: false })
20
+ expect(checkPeriod('otuz')).toMatchObject({ ok: false })
21
+ })
22
+
23
+ it('bakım tarihi: geçerli gün, ileri tarih ve çok eski tarih reddedilir', () => {
24
+ expect(isCalendarDate('2026-02-30')).toBe(false)
25
+ expect(checkMaintenanceDate('2026-10-20', '2026-10-20')).toEqual({ ok: true, value: '2026-10-20' })
26
+ expect(checkMaintenanceDate('2026-10-21', '2026-10-20')).toMatchObject({ ok: false })
27
+ expect(checkMaintenanceDate('1999-12-31', '2026-10-20')).toMatchObject({ ok: false })
28
+ expect(checkMaintenanceDate('20.10.2026', '2026-10-20')).toMatchObject({ ok: false })
29
+ })
30
+
31
+ it('durum: geçmiş geciken, pencere içi yaklaşan (bugün dahil), sonrası planlı', () => {
32
+ expect(dueStatus('2026-10-19', '2026-10-20')).toBe('overdue')
33
+ expect(dueStatus('2026-10-20', '2026-10-20')).toBe('due-soon')
34
+ expect(dueStatus('2026-10-27', '2026-10-20')).toBe('due-soon')
35
+ expect(dueStatus('2026-10-28', '2026-10-20')).toBe('scheduled')
36
+ })
37
+
38
+ it('hatırlatma: aynı bakım tarihi için bir kez; bakım kaydedilince yeni tarih yeniden hatırlatılır', () => {
39
+ expect(needsReminder({ next_due_date: '2026-10-25', reminder_sent_for: null }, '2026-10-20')).toBe(true)
40
+ expect(needsReminder({ next_due_date: '2026-10-25', reminder_sent_for: '2026-10-25' }, '2026-10-20')).toBe(false)
41
+ expect(needsReminder({ next_due_date: '2026-11-24', reminder_sent_for: '2026-10-25' }, '2026-11-20')).toBe(true)
42
+ expect(needsReminder({ next_due_date: '2026-11-24', reminder_sent_for: null }, '2026-10-20')).toBe(false)
43
+ })
44
+
45
+ it('gruplama: kişi başına tek e-posta, en acil önce, kişi sınırı', () => {
46
+ const row = (id: string, who: string, due: string): DueRow => ({ id, code: id, name: id, location: null, next_due_date: due, responsible_org_id: who, reminder_sent_for: null })
47
+ const groups = groupByResponsible([row('c', 'b'.repeat(24), '2026-10-22'), row('a', 'a'.repeat(24), '2026-10-25'), row('b', 'B'.repeat(24), '2026-10-21'), row('d', 'c'.repeat(24), '2026-10-30')], 2)
48
+ expect(groups.map((g) => [g.responsible, g.items.map((i) => i.id)])).toEqual([
49
+ ['b'.repeat(24), ['b', 'c']],
50
+ ['a'.repeat(24), ['a']],
51
+ ])
52
+ })
53
+
54
+ it('bugün İstanbul saatine göre', () => {
55
+ expect(todayKey(new Date('2026-10-20T20:59:00Z'))).toBe('2026-10-20')
56
+ expect(todayKey(new Date('2026-10-20T21:00:00Z'))).toBe('2026-10-21')
57
+ })
58
+ })
@@ -0,0 +1,106 @@
1
+ // Bakım takvimi kuralları — SAF fonksiyonlar (veritabanı ve ağ yok; `schedule.test.ts` sınar). Sıradaki bakım tarihi
2
+ // HER ZAMAN sunucuda hesaplanır: son bakım + bakım aralığı (gün). İstemciden gelen tarih yazılmaz. Kuralı değiştiren bu
3
+ // dosyayı, `queries.ts` içindeki aynı kuralın SQL karşılığını (`NEXT_DUE_SQL`) ve testleri birlikte değiştirir.
4
+
5
+ /** Bakım aralığı sınırları (gün). ⚠️ İş kuralıdır — keşif görüşmesinde kullanıcıyla netleştirin. */
6
+ export const PERIOD_MIN_DAYS = 1
7
+ export const PERIOD_MAX_DAYS = 3650
8
+ /** Sıradaki bakıma kaç gün kala hatırlatılır (geciken bakım da hatırlatılır). ⚠️ İş kuralıdır. */
9
+ export const REMIND_DAYS = 7
10
+ /** Kabul edilen en eski bakım tarihi (yanlış yazılan yılı yakalar). */
11
+ export const EARLIEST_DATE = '2000-01-01'
12
+
13
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
14
+ const DAY_MS = 86_400_000
15
+
16
+ /** `YYYY-AA-GG` biçiminde gerçek bir takvim günü mü (31 Şubat değil)? */
17
+ export function isCalendarDate(value: unknown): value is string {
18
+ if (typeof value !== 'string') return false
19
+ const m = DATE_RE.exec(value)
20
+ if (!m) return false
21
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])))
22
+ return d.toISOString().slice(0, 10) === value
23
+ }
24
+
25
+ /** İstanbul saatiyle bugünün tarihi (Türkiye'de yaz saati uygulaması yoktur: UTC+3). */
26
+ export function todayKey(at: Date): string {
27
+ return new Date(at.getTime() + 3 * 3_600_000).toISOString().slice(0, 10)
28
+ }
29
+
30
+ /** Takvim gününe gün ekler (`YYYY-AA-GG`). */
31
+ export function addDays(date: string, days: number): string {
32
+ if (!isCalendarDate(date)) throw new Error(`geçersiz tarih: ${date}`)
33
+ return new Date(Date.parse(`${date}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10)
34
+ }
35
+
36
+ /** `from`'dan `to`'ya kaç gün (geçmişse eksi). */
37
+ export function daysBetween(from: string, to: string): number {
38
+ return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS)
39
+ }
40
+
41
+ /** Sıradaki bakım = son bakım + aralık. ⛔ `queries.ts` → `NEXT_DUE_SQL` ile AYNI kural (test ikisini karşılaştırır). */
42
+ export function nextDueDate(lastMaintenance: string, periodDays: number): string {
43
+ return addDays(lastMaintenance, periodDays)
44
+ }
45
+
46
+ export type Check<T> = { ok: true; value: T } | { ok: false; message: string }
47
+
48
+ /** Bakım aralığı: tam gün, sınırlar içinde. */
49
+ export function checkPeriod(raw: unknown): Check<number> {
50
+ const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN
51
+ if (!Number.isInteger(n)) return { ok: false, message: 'Bakım aralığı tam gün sayısı olmalı.' }
52
+ if (n < PERIOD_MIN_DAYS || n > PERIOD_MAX_DAYS) return { ok: false, message: `Bakım aralığı ${PERIOD_MIN_DAYS} ile ${PERIOD_MAX_DAYS} gün arasında olmalı.` }
53
+ return { ok: true, value: n }
54
+ }
55
+
56
+ /** Bakım tarihi: geçerli bir gün, bugünden ileri değil, `EARLIEST_DATE`'ten eski değil. */
57
+ export function checkMaintenanceDate(raw: unknown, today: string): Check<string> {
58
+ if (!isCalendarDate(raw)) return { ok: false, message: 'Tarih YYYY-AA-GG biçiminde geçerli bir gün olmalı.' }
59
+ if (raw > today) return { ok: false, message: 'İleri tarihli bakım girilemez.' }
60
+ if (raw < EARLIEST_DATE) return { ok: false, message: `Tarih ${EARLIEST_DATE} ya da daha yeni olmalı.` }
61
+ return { ok: true, value: raw }
62
+ }
63
+
64
+ export type DueStatus = 'overdue' | 'due-soon' | 'scheduled'
65
+
66
+ /** Durum: sıradaki bakım geçtiyse geciken; `REMIND_DAYS` içindeyse yaklaşan; değilse planlı. Bugün = yaklaşan. */
67
+ export function dueStatus(nextDue: string, today: string, windowDays = REMIND_DAYS): DueStatus {
68
+ if (nextDue < today) return 'overdue'
69
+ return daysBetween(today, nextDue) <= windowDays ? 'due-soon' : 'scheduled'
70
+ }
71
+
72
+ /** Hatırlatma gerekir mi: pencere içinde (ya da gecikmiş) ve BU bakım tarihi için daha önce hatırlatılmamış. */
73
+ export function needsReminder(row: { next_due_date: string | null; reminder_sent_for: string | null }, today: string, windowDays = REMIND_DAYS): boolean {
74
+ if (!row.next_due_date) return false
75
+ return daysBetween(today, row.next_due_date) <= windowDays && row.reminder_sent_for !== row.next_due_date
76
+ }
77
+
78
+ export interface DueRow {
79
+ id: string
80
+ code: string
81
+ name: string
82
+ location: string | null
83
+ next_due_date: string
84
+ responsible_org_id: string
85
+ reminder_sent_for: string | null
86
+ }
87
+
88
+ /**
89
+ * Hatırlatılacak bakımları sorumluya göre gruplar: kişi başına TEK e-posta. Bir koşumda en çok `maxGroups` kişiye
90
+ * gider (platformun istek başına dış çağrı sınırı); sıra en acil bakımdır. Kalanlar damgalanmadığı için ertesi gün gider.
91
+ */
92
+ /** Bir hatırlatma e-postasında listelenen en çok ekipman (kalanlar damgalanmaz, sonraki koşumda gider). */
93
+ export const REMINDER_ITEMS_PER_MAIL = 20
94
+
95
+ export function groupByResponsible(rows: readonly DueRow[], maxGroups: number, maxItems = REMINDER_ITEMS_PER_MAIL): Array<{ responsible: string; items: DueRow[] }> {
96
+ const groups = new Map<string, DueRow[]>()
97
+ const sorted = [...rows].sort((a, b) => (a.next_due_date < b.next_due_date ? -1 : a.next_due_date > b.next_due_date ? 1 : a.code < b.code ? -1 : 1))
98
+ for (const row of sorted) {
99
+ const key = row.responsible_org_id.toLowerCase()
100
+ const list = groups.get(key)
101
+ if (list) {
102
+ if (list.length < maxItems) list.push(row)
103
+ } else if (groups.size < maxGroups) groups.set(key, [row])
104
+ }
105
+ return [...groups].map(([responsible, items]) => ({ responsible, items }))
106
+ }
@@ -0,0 +1,299 @@
1
+ // {{nameTr}} — sayfa (dört görünüm: Bakım takvimi · Gecikenler · Bakım kayıtları · Ekipmanlar). Liste, süzgeç, form,
2
+ // kişi seçici ve dosya yükleyici hazır bileşenlerdir; seçenekler ve alan adları veri modelinden gelir. Sıradaki bakım
3
+ // tarihi, geciken/yaklaşan sayıları ve geciken listesinin süzgeci SUNUCUDA hesaplanır; sayfa yalnız gösterir.
4
+ import type { OneNavigationItem } from '@devoplus/one-sdk'
5
+ import { dataModel } from './data/types.ts'
6
+ import { renderClientScript } from './lib/client.ts'
7
+ import { label } from './lib/model.ts'
8
+ import { escapeHtml, i18n, renderPage, translator, view, type Lang, type Strings, type Theme } from './lib/page.ts'
9
+
10
+ export const STRINGS: Strings = {
11
+ tr: {
12
+ title: {{nameTrJs}},
13
+ waiting: "Portal'dan oturum bekleniyor…",
14
+ networkError: 'İstek gönderilemedi (ağ hatası).',
15
+ requestFailed: 'İşlem yapılamadı.',
16
+ readOnly: 'Vekalet ya da Yerine Görüntüle oturumundasınız: bu uygulamada yalnız görüntüleme yapabilirsiniz.',
17
+ scheduleIntro: 'Kullanımdaki ekipmanlar, sıradaki bakım tarihine göre sıralı. Sıradaki tarih son bakım ve bakım aralığından hesaplanır.',
18
+ dueBanner: '{overdue} ekipmanın bakımı gecikti, {soon} ekipmanın bakımı {days} gün içinde.',
19
+ dueNone: 'Geciken ya da {days} gün içinde yaklaşan bakım yok.',
20
+ lastRun: 'Son hatırlatma kontrolü: {day} — {sent} kişiye e-posta gönderildi.',
21
+ lastRunConsent: 'Son hatırlatma kontrolü: {day} — e-postalar gönderilemedi: arka plan e-postası için portal yöneticisinin uygulama kurulumunda onay vermesi gerekiyor.',
22
+ noRun: 'Henüz hatırlatma kontrolü yapılmadı.',
23
+ status: 'Durum',
24
+ statusOverdue: 'Gecikti',
25
+ statusSoon: 'Yaklaşıyor',
26
+ statusScheduled: 'Planlı',
27
+ search: 'Ara',
28
+ scheduleTable: 'Bakım takvimi',
29
+ overdueIntro: 'Sıradaki bakım tarihi geçmiş ekipmanlar. Liste Excel ya da CSV olarak alınabilir.',
30
+ overdueTable: 'Geciken bakımlar',
31
+ recordsIntro: 'Yapılan bakımlar. Bakım kaydı girilince ekipmanın son ve sıradaki bakım tarihi birlikte güncellenir. Kayıtlar değiştirilmez.',
32
+ recordForm: 'Bakım kaydı gir',
33
+ recordHint: 'Bakımı ekipmanın sorumlusu ya da bakım sorumlusu izni olan kişi kaydeder.',
34
+ recordSaved: 'Bakım kaydedildi. Sıradaki bakım: {next}.',
35
+ recordsTable: 'Bakım kayıtları',
36
+ download: 'Ek dosyayı indir',
37
+ downloadFailed: 'Ek dosya indirilemedi.',
38
+ noAttachment: 'Bu kayıtta ek dosya yok.',
39
+ equipmentIntro: 'Ekipman tanımları. Son bakım tarihi yalnız ilk tanımda girilir; sonrasında bakım kaydıyla değişir.',
40
+ equipmentTable: 'Ekipmanlar',
41
+ managersOnly: 'Bu işlem yalnız bakım sorumlusu izni olan kişilere açıktır.',
42
+ equipmentForm: 'Ekipman bilgileri',
43
+ newEquipment: 'Yeni ekipman',
44
+ equipmentSaved: 'Ekipman kaydedildi.',
45
+ responsible: 'Sorumlu (hatırlatma e-postasını alır)',
46
+ firstDate: 'Son bakım (ya da başlangıç) tarihi',
47
+ },
48
+ en: {
49
+ title: {{nameEnJs}},
50
+ waiting: 'Waiting for the session from the Portal…',
51
+ networkError: 'Request could not be sent (network error).',
52
+ requestFailed: 'The action could not be completed.',
53
+ readOnly: 'You are in a Delegation or View as User session: you can only view this app.',
54
+ scheduleIntro: 'Equipment in use, sorted by next due date. The next date is computed from the last maintenance and the interval.',
55
+ dueBanner: '{overdue} items are overdue, {soon} items are due within {days} days.',
56
+ dueNone: 'No maintenance is overdue or due within {days} days.',
57
+ lastRun: 'Latest reminder check: {day} — e-mail sent to {sent} people.',
58
+ lastRunConsent: 'Latest reminder check: {day} — e-mails could not be sent: the portal administrator must approve background e-mail in the app installation.',
59
+ noRun: 'No reminder check has run yet.',
60
+ status: 'Status',
61
+ statusOverdue: 'Overdue',
62
+ statusSoon: 'Due soon',
63
+ statusScheduled: 'Scheduled',
64
+ search: 'Search',
65
+ scheduleTable: 'Maintenance schedule',
66
+ overdueIntro: 'Equipment whose next maintenance date has passed. The list can be exported to Excel or CSV.',
67
+ overdueTable: 'Overdue maintenance',
68
+ recordsIntro: 'Completed maintenance. Recording maintenance updates the last and next dates of the equipment together. Records cannot be changed.',
69
+ recordForm: 'Record maintenance',
70
+ recordHint: 'Maintenance is recorded by the responsible person of the equipment or by someone with the maintenance manager permission.',
71
+ recordSaved: 'Maintenance saved. Next due: {next}.',
72
+ recordsTable: 'Maintenance records',
73
+ download: 'Download attachment',
74
+ downloadFailed: 'The attachment could not be downloaded.',
75
+ noAttachment: 'This record has no attachment.',
76
+ equipmentIntro: 'Equipment definitions. The last maintenance date is entered only when the item is created; afterwards it changes with maintenance records.',
77
+ equipmentTable: 'Equipment',
78
+ managersOnly: 'Only people with the maintenance manager permission can do this.',
79
+ equipmentForm: 'Equipment details',
80
+ newEquipment: 'New equipment',
81
+ equipmentSaved: 'Equipment saved.',
82
+ responsible: 'Responsible (gets the reminder e-mail)',
83
+ firstDate: 'Last maintenance (or start) date',
84
+ },
85
+ }
86
+
87
+ const MODEL = {
88
+ manage: '{{permissionPrefix}}Manage',
89
+ labels: {
90
+ code: label(dataModel, 'equipment', 'code'),
91
+ name: label(dataModel, 'equipment', 'name'),
92
+ category: label(dataModel, 'equipment', 'category'),
93
+ location: label(dataModel, 'equipment', 'location'),
94
+ responsibleName: label(dataModel, 'equipment', 'responsible_name'),
95
+ period: label(dataModel, 'equipment', 'period_days'),
96
+ last: label(dataModel, 'equipment', 'last_maintenance_date'),
97
+ next: label(dataModel, 'equipment', 'next_due_date'),
98
+ active: label(dataModel, 'equipment', 'active'),
99
+ equipment: label(dataModel, 'maintenance_records', 'equipment_id'),
100
+ equipmentName: label(dataModel, 'maintenance_records', 'equipment_name'),
101
+ performedOn: label(dataModel, 'maintenance_records', 'performed_on'),
102
+ note: label(dataModel, 'maintenance_records', 'note'),
103
+ attachment: label(dataModel, 'maintenance_records', 'attachment'),
104
+ attachmentName: label(dataModel, 'maintenance_records', 'attachment_name'),
105
+ performer: label(dataModel, 'maintenance_records', 'performer_name'),
106
+ recordNext: label(dataModel, 'maintenance_records', 'next_due_date'),
107
+ },
108
+ }
109
+
110
+ export function renderShell(lang: Lang, theme: Theme, path: string): string {
111
+ const tx = translator(STRINGS, lang)
112
+ const btn = (id: string, key: string, cls = 'one-btn') => `<button type="button" class="${cls}" id="${id}" data-i18n="${key}">${escapeHtml(tx(key))}</button>`
113
+ const body = [
114
+ view(
115
+ '/',
116
+ path,
117
+ `${i18n(tx, 'scheduleIntro', 'p', 'class="one-muted"')}
118
+ <p><strong id="dueBanner"></strong></p>
119
+ <p class="one-muted" id="runNote"></p>
120
+ <one-filter id="scheduleFilter" for="scheduleTable"></one-filter>
121
+ <one-table id="scheduleTable" src="/api/equipment?active=true" paging="page" export="xlsx csv" caption="${escapeHtml(tx('scheduleTable'))}"></one-table>`,
122
+ ),
123
+ view(
124
+ '/overdue',
125
+ path,
126
+ `${i18n(tx, 'overdueIntro', 'p', 'class="one-muted"')}
127
+ <one-table id="overdueTable" src="/api/overdue/equipment" paging="page" export="xlsx csv" caption="${escapeHtml(tx('overdueTable'))}"></one-table>`,
128
+ ),
129
+ view(
130
+ '/records',
131
+ path,
132
+ `${i18n(tx, 'recordsIntro', 'p', 'class="one-muted"')}
133
+ <section id="recordBox" class="one-card" hidden>
134
+ <h2 data-i18n="recordForm">${escapeHtml(tx('recordForm'))}</h2>
135
+ ${i18n(tx, 'recordHint', 'p', 'class="one-muted"')}
136
+ <one-form id="recordForm" no-cancel></one-form>
137
+ </section>
138
+ <one-table id="recordsTable" src="/api/maintenance_records" paging="page" export="xlsx csv" clickable caption="${escapeHtml(tx('recordsTable'))}"></one-table>`,
139
+ ),
140
+ view(
141
+ '/equipment',
142
+ path,
143
+ `${i18n(tx, 'equipmentIntro', 'p', 'class="one-muted"')}
144
+ <one-table id="equipmentTable" src="/api/equipment" paging="page" export="xlsx csv" clickable caption="${escapeHtml(tx('equipmentTable'))}"></one-table>
145
+ ${i18n(tx, 'managersOnly', 'p', 'class="one-muted" id="equipmentDenied"')}
146
+ <section id="equipmentEditor" class="one-card" hidden>
147
+ <h2 data-i18n="equipmentForm">${escapeHtml(tx('equipmentForm'))}</h2>
148
+ <div class="app-toolbar">${btn('newEquipment', 'newEquipment')}</div>
149
+ <one-form id="equipmentForm" action="/api/equipment" method="POST"></one-form>
150
+ </section>`,
151
+ ),
152
+ ].join('\n')
153
+ return renderPage({ lang, theme, title: tx('title'), body })
154
+ }
155
+
156
+ export function renderShellScript(navigation: readonly OneNavigationItem[], options: { actingWrites?: boolean } = {}): string {
157
+ return renderClientScript({ strings: STRINGS, navigation, ...(options.actingWrites !== undefined ? { actingWrites: options.actingWrites } : {}), body: pageScript() })
158
+ }
159
+
160
+ function pageScript(): string {
161
+ return `
162
+ var M = ${JSON.stringify(MODEL).replace(/<\//g, '<\\/')};
163
+ var scheduleTable = $('scheduleTable'), overdueTable = $('overdueTable'), recordsTable = $('recordsTable'), equipmentTable = $('equipmentTable');
164
+ var recordForm = $('recordForm'), equipmentForm = $('equipmentForm');
165
+ var TODAY = null, DAYS = 7, equipmentOptions = [];
166
+
167
+ // Durum yalnız gösterimdir; geciken listesinin süzgeci ve sayıları sunucudadır.
168
+ function dayDiff(a, b) { return Math.round((Date.parse(b + 'T00:00:00Z') - Date.parse(a + 'T00:00:00Z')) / 86400000); }
169
+ function statusText(row) {
170
+ if (!TODAY || !row.next_due_date) return '';
171
+ if (row.next_due_date < TODAY) return t('statusOverdue');
172
+ return dayDiff(TODAY, row.next_due_date) <= DAYS ? t('statusSoon') : t('statusScheduled');
173
+ }
174
+ var scheduleColumns = [
175
+ { key: 'next_due_date', label: M.labels.next, type: 'date' },
176
+ { key: 'status', label: { tr: S.tr.status, en: S.en.status }, sortable: false, format: function (v, row) { return statusText(row); } },
177
+ { key: 'code', label: M.labels.code },
178
+ { key: 'name', label: M.labels.name },
179
+ { key: 'category', label: M.labels.category },
180
+ { key: 'location', label: M.labels.location },
181
+ { key: 'responsible_name', label: M.labels.responsibleName },
182
+ { key: 'last_maintenance_date', label: M.labels.last, type: 'date' },
183
+ { key: 'period_days', label: M.labels.period, type: 'number' }
184
+ ];
185
+ scheduleTable.columns = scheduleColumns;
186
+ overdueTable.columns = scheduleColumns;
187
+ $('scheduleFilter').fields = [{ key: 'q', label: { tr: S.tr.search, en: S.en.search }, type: 'text' }];
188
+ recordsTable.columns = [
189
+ { key: 'performed_on', label: M.labels.performedOn, type: 'date' },
190
+ { key: 'equipment_name', label: M.labels.equipmentName },
191
+ { key: 'note', label: M.labels.note },
192
+ { key: 'performer_name', label: M.labels.performer },
193
+ { key: 'next_due_date', label: M.labels.recordNext, type: 'date' },
194
+ { key: 'attachment_name', label: M.labels.attachmentName }
195
+ ];
196
+ equipmentTable.columns = [
197
+ { key: 'code', label: M.labels.code },
198
+ { key: 'name', label: M.labels.name },
199
+ { key: 'category', label: M.labels.category },
200
+ { key: 'location', label: M.labels.location },
201
+ { key: 'period_days', label: M.labels.period, type: 'number' },
202
+ { key: 'next_due_date', label: M.labels.next, type: 'date' },
203
+ { key: 'responsible_name', label: M.labels.responsibleName },
204
+ { key: 'active', label: M.labels.active, type: 'boolean' }
205
+ ];
206
+ var baseFields = [
207
+ { key: 'code', label: M.labels.code, required: true, maxLength: 40 },
208
+ { key: 'name', label: M.labels.name, required: true, maxLength: 120 },
209
+ { key: 'category', label: M.labels.category, maxLength: 60 },
210
+ { key: 'location', label: M.labels.location, maxLength: 120 },
211
+ { key: 'period_days', label: M.labels.period, type: 'number', min: 1, max: 3650, required: true },
212
+ { key: 'responsible', label: { tr: S.tr.responsible, en: S.en.responsible }, type: 'person' },
213
+ { key: 'active', label: M.labels.active, type: 'boolean' }
214
+ ];
215
+ // Son bakım tarihi YALNIZ yeni ekipmanda sorulur; düzenlemede alan formda yoktur (sunucu da kabul etmez).
216
+ function newEquipment() {
217
+ equipmentForm.fields = baseFields.concat([{ key: 'last_maintenance_date', label: { tr: S.tr.firstDate, en: S.en.firstDate }, type: 'date', required: true, max: TODAY || undefined }]);
218
+ equipmentForm.setAttribute('action', '/api/equipment'); equipmentForm.setAttribute('method', 'POST');
219
+ equipmentForm.value = { period_days: 30, active: true, last_maintenance_date: TODAY };
220
+ one.setTitle(null);
221
+ }
222
+ function recordFields() {
223
+ recordForm.fields = [
224
+ { key: 'equipment', label: M.labels.equipment, type: 'choice', options: equipmentOptions, required: true },
225
+ { key: 'performed_on', label: M.labels.performedOn, type: 'date', required: true, max: TODAY || undefined },
226
+ { key: 'note', label: M.labels.note, type: 'longText', maxLength: 2000 },
227
+ { key: 'attachment', label: M.labels.attachment, type: 'file', accept: 'image/*,.pdf', maxSize: 5242880, action: '/api/files' }
228
+ ];
229
+ }
230
+ function loadEquipmentOptions() {
231
+ return loadAll('/api/equipment?active=true').then(function (r) {
232
+ if (!r.ok) { one.toast({ severity: 'error', summary: problem(r) }); return; }
233
+ equipmentOptions = r.body.items.map(function (x) { return { value: x.id, label: x.code + ' - ' + x.name }; });
234
+ recordFields();
235
+ recordForm.value = { performed_on: TODAY };
236
+ });
237
+ }
238
+ function loadSummary() {
239
+ return api('GET', '/api/summary').then(function (r) {
240
+ if (!r.ok) { one.toast({ severity: 'error', summary: problem(r) }); return; }
241
+ var s = r.body;
242
+ TODAY = s.today; DAYS = s.remindDays;
243
+ $('dueBanner').textContent = s.overdue + s.dueSoon > 0 ? t('dueBanner', { overdue: fmtNumber(s.overdue), soon: fmtNumber(s.dueSoon), days: s.remindDays }) : t('dueNone', { days: s.remindDays });
244
+ $('runNote').textContent = !s.lastRun ? t('noRun') : s.lastRun.consent_needed ? t('lastRunConsent', { day: fmtDate(s.lastRun.run_key) }) : t('lastRun', { day: fmtDate(s.lastRun.run_key), sent: fmtNumber(s.lastRun.sent_count) });
245
+ scheduleTable.reload(); overdueTable.reload();
246
+ });
247
+ }
248
+ recordForm.addEventListener('save', function (e) {
249
+ e.preventDefault();
250
+ var v = e.detail.value;
251
+ api('POST', '/api/equipment/' + encodeURIComponent(v.equipment) + '/maintenance', { performed_on: v.performed_on, note: v.note || '', attachment: v.attachment || null }).then(function (r) {
252
+ if (!r.ok) {
253
+ if (r.body && r.body.errors) recordForm.setErrors(r.body.errors, problem(r));
254
+ else one.toast({ severity: 'error', summary: problem(r) });
255
+ return;
256
+ }
257
+ one.toast({ severity: 'success', summary: t('recordSaved', { next: fmtDate(r.body.equipment.next_due_date) }) });
258
+ recordForm.value = { performed_on: TODAY };
259
+ recordsTable.reload(); equipmentTable.reload(); loadSummary();
260
+ });
261
+ });
262
+ recordsTable.addEventListener('rowclick', function (e) {
263
+ var r = e.detail.row;
264
+ if (!r.attachment) { one.toast({ severity: 'info', summary: t('noAttachment') }); return; }
265
+ one.fetch('/api/maintenance_records/' + encodeURIComponent(r.id) + '/attachment').then(function (res) {
266
+ if (!res.ok) { one.toast({ severity: 'error', summary: t('downloadFailed') }); return; }
267
+ return res.arrayBuffer().then(function (data) {
268
+ return one.download({ name: r.attachment_name || 'ek', mime: (res.headers.get('content-type') || 'application/octet-stream').split(';')[0], data: data });
269
+ });
270
+ }, function () { one.toast({ severity: 'error', summary: t('downloadFailed') }); });
271
+ });
272
+ equipmentTable.addEventListener('rowclick', function (e) {
273
+ if (!can(M.manage) || !canWrite()) return;
274
+ var r = e.detail.row;
275
+ equipmentForm.fields = baseFields;
276
+ equipmentForm.setAttribute('action', '/api/equipment/' + encodeURIComponent(r.id));
277
+ equipmentForm.setAttribute('method', 'PATCH');
278
+ equipmentForm.value = Object.assign({}, r, { responsible: r.responsible_org_id ? { id: r.responsible_org_id, name: r.responsible_name || '' } : null });
279
+ one.setTitle(r.name);
280
+ });
281
+ $('newEquipment').addEventListener('click', newEquipment);
282
+ equipmentForm.addEventListener('saved', function (e) {
283
+ e.preventDefault();
284
+ one.toast({ severity: 'success', summary: t('equipmentSaved') });
285
+ newEquipment(); equipmentTable.reload(); loadEquipmentOptions(); loadSummary();
286
+ });
287
+ onSession(function () {
288
+ var manager = can(M.manage) && canWrite();
289
+ $('equipmentEditor').toggleAttribute('hidden', !manager); $('equipmentDenied').toggleAttribute('hidden', manager);
290
+ if (manager) newEquipment();
291
+ });
292
+ onLang(function () { loadSummary(); });
293
+ onReady(function () {
294
+ loadSummary().then(function () {
295
+ if (canWrite()) { $('recordBox').removeAttribute('hidden'); loadEquipmentOptions(); }
296
+ });
297
+ });
298
+ `
299
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "scopes": ["orgschema.read", "mail.execute"],
3
+ "services": { "database": true, "files": true, "cache": false },
4
+ "menuIcon": "pi pi-wrench",
5
+ "navigation": [
6
+ { "path": "/", "label": { "tr": "Bakım takvimi", "en": "Schedule" } },
7
+ { "path": "/overdue", "label": { "tr": "Gecikenler", "en": "Overdue" } },
8
+ { "path": "/records", "label": { "tr": "Bakım kayıtları", "en": "Maintenance log" } },
9
+ { "path": "/equipment", "label": { "tr": "Ekipmanlar", "en": "Equipment" }, "permission": "{{permissionPrefix}}Manage" }
10
+ ],
11
+ "permissions": [
12
+ { "code": "{{permissionPrefix}}Manage", "name": { "tr": "Ekipmanları ve bakım kayıtlarını yönet", "en": "Manage equipment and maintenance records" } }
13
+ ],
14
+ "agent": {
15
+ "summary": {
16
+ "tr": "Bakım takvimi uygulaması: cihaz ve ekipmanların periyodik bakımı, sıradaki bakım tarihleri, yaklaşan ve geciken bakımlar.",
17
+ "en": "Maintenance schedule app: periodic maintenance of devices and equipment, next due dates, upcoming and overdue work."
18
+ },
19
+ "tools": [
20
+ {
21
+ "name": "upcoming-maintenance",
22
+ "title": { "tr": "Yaklaşan bakımlar", "en": "Upcoming maintenance" },
23
+ "description": {
24
+ "tr": "Sıradaki bakım tarihi bugünden itibaren verilen gün içinde olan ya da geçmiş (geciken) ekipmanları, en acil olan önce döndürür. Salt okunur.",
25
+ "en": "Returns equipment whose next maintenance is due within the given number of days or already overdue, most urgent first. Read-only."
26
+ },
27
+ "readOnly": true,
28
+ "permission": "{{permissionCode}}",
29
+ "input": {
30
+ "type": "object",
31
+ "properties": {
32
+ "days": { "type": "integer", "minimum": 0, "maximum": 90, "description": "Bugünden itibaren kaç gün (varsayılan 14; 0 yalnız bugün ve gecikenler)" },
33
+ "limit": { "type": "integer", "minimum": 1, "maximum": 50, "description": "En çok kaç ekipman (varsayılan 20)" }
34
+ }
35
+ }
36
+ },
37
+ {
38
+ "name": "search-equipment",
39
+ "title": { "tr": "Ekipman ara", "en": "Search equipment" },
40
+ "description": {
41
+ "tr": "Ekipman kodunda, adında ya da konumunda aranan ifade geçen ekipmanları son ve sıradaki bakım tarihleri ve sorumlusuyla döndürür. Salt okunur.",
42
+ "en": "Returns equipment whose code, name or location contains the query, with last and next maintenance dates and the responsible person. Read-only."
43
+ },
44
+ "readOnly": true,
45
+ "permission": "{{permissionCode}}",
46
+ "input": {
47
+ "type": "object",
48
+ "properties": {
49
+ "query": { "type": "string", "minLength": 1, "maxLength": 80, "description": "Aranan ifade (ekipman kodu, ad ya da konum)" },
50
+ "limit": { "type": "integer", "minimum": 1, "maximum": 50, "description": "En çok kaç ekipman (varsayılan 10)" }
51
+ },
52
+ "required": ["query"]
53
+ }
54
+ }
55
+ ]
56
+ },
57
+ "jobs": {
58
+ "schedules": [
59
+ { "name": "daily-maintenance-reminders", "cron": "0 8 * * *", "timeZone": "Europe/Istanbul", "description": "Yaklaşan ve geciken bakımları sorumlusuna e-postayla hatırlatır" }
60
+ ],
61
+ "queues": [],
62
+ "services": ["mail.execute"]
63
+ }
64
+ }
@@ -0,0 +1,41 @@
1
+ ## Bu uygulamanın şablonu: eğitim ve sertifika takibi (`egitim-sertifika`)
2
+
3
+ Uygulama `create-devoplus-one-app --template egitim-sertifika` ile üretildi. Kullanıcının ihtiyacını bu şablona göre DEĞİŞTİRİN;
4
+ baştan yazmayın. Dosya haritası:
5
+
6
+ | Dosya | Ne |
7
+ |---|---|
8
+ | `one.data.json` | Veri modeli: `cert_types` (tür) + `certificates` (kişi başına kayıt) + `reminder_runs` (günlük hatırlatma kontrolü). Değişince `pnpm data:generate` |
9
+ | `src/app.ts` | Uçlar: kendi kayıtlarım, ekibim (org şemasından), bütün kayıtlar, kayıt yazma, yönetici yenileme, belge, türler, özet |
10
+ | `src/certificate.ts` | Saf kurallar: geçerlilik sonu (ay ekleme), doğrulama, durum, ekip, gruplama + `certificate.test.ts` |
11
+ | `src/queries.ts` | Okumalar; "yenilendi" kuralı (`SUPERSEDED_SQL`) |
12
+ | `src/jobs.ts` | Sabah işi `daily-certificate-reminders` (kişiye + yöneticisine e-posta; kişi başına tek e-posta; damga ile tekrar yok) |
13
+ | `src/names.ts` | Uygulama adı ve eğitim sorumlusu izni |
14
+ | `src/shell.ts` | Sayfa: sertifikalarım, ekibim, tüm kayıtlar, türler |
15
+ | `src/agent.ts` · `AGENT_GUIDE.md` | Devoplus yapay zekâsının araçları ve rehberi |
16
+ | `src/lib/*` | Şablonların ortak parçaları — gerekmedikçe değiştirmeyin |
17
+
18
+ Değişmezler (bozmayın):
19
+
20
+ - **Geçerlilik sonu SUNUCUDA hesaplanır:** tamamlanma tarihi + türün süresi (ay; hedef ayda o gün yoksa ayın son günü).
21
+ Kayıt uçları `expires_on`, `manager_org_id`, `manager_name`, `person_name`, `type_name` ve hatırlatma damgalarını
22
+ (`reminder_sent_for`, `manager_reminder_sent_for`, `reminder_failed_on`) istemciden ALMAZ (400). Türün süresi değişince eski kayıtlar değişmez.
23
+ - **Kişi ve yönetici organizasyon şemasından SUNUCUDA çözülür:** kişi seçicinin değeri şemada aranır (yoksa 400), yönetici
24
+ `approvalChainReport(..., { maxDepth: 1 })` ile alınır (pasif yönetici atlanır).
25
+ - **"Sertifikalarım" kişiyi oturumdan, "Ekibim" ekibi org şemasından çözer** (işlevsel yöneticisi oturumdaki kişi olan
26
+ aktif çalışanlar). İstemciden kişi ya da ekip listesi ALINMAZ; ekibi olmayana 403. Belgeyi yalnız kişinin kendisi,
27
+ doğrudan yöneticisi ya da eğitim sorumlusu indirir; başkasına 404.
28
+ - **Bütün kayıtlar kişisel veridir:** liste ucu ve Devoplus yapay zekâsının `expiring-certificates` aracı eğitim
29
+ sorumlusu iznine (`…Manage`) bağlıdır. Bunu genişletmeyin.
30
+ - **Hatırlatma alıcı başına bir kez gider:** gönderimden önce koşullu damga, kişiye `reminder_sent_for`, yöneticiye
31
+ `manager_reminder_sent_for` (= hatırlatılan geçerlilik sonu). Posta servisi alıcılardan birini atlarsa (`skipped`)
32
+ yalnız onun damgası geri alınır; ertesi gün yalnız o yeniden denenir, iletiyi almış olan aynı kaydı ikinci kez almaz.
33
+ "Yöneticileri yenile" yöneticisi değişen kaydın yönetici damgasını sıfırlar. Hatırlatması ulaşmayan kayıt
34
+ (`reminder_failed_on`) sıranın sonuna geçer: hiç ulaşamayan bir alıcı öteki kişileri hatırlatmasız bırakmaz. Yenilenen
35
+ (aynı kişi ve türde daha yeni kaydı olan) sertifika hatırlatılmaz.
36
+ - **Zamanlanmış iş kullanıcısızdır:** e-posta uygulamanın kimliğiyle gider (`jobs.services: ["mail.execute"]` +
37
+ kurulumda yönetici onayı). Organizasyon şeması işte kullanılamaz; alıcılar kayıttaki kişi ve yönetici kimlikleridir.
38
+ Yöneticiler değiştiyse `POST /api/certificates/refresh-managers` kayıtları günceller.
39
+ - **Bir koşumda en çok `MAILS_PER_RUN` (5) kişi** için e-posta gider (platformun istek başına dış çağrı sınırı 10); sayıyı
40
+ artırmayın.
41
+ - Mobil bildirim bugün hiçbir ortamda açık değildir; "telefonuma bildirim gelsin" isteğinde bunu söyleyin.
@@ -0,0 +1,46 @@
1
+ # {{nameTr}}
2
+
3
+ Çalışanların eğitim ve sertifikalarını, geçerlilik sürelerini ve yenileme zamanlarını izleyen bir uygulama. Devoplus
4
+ Portal'ın içinde, Portal menüsünden açılır. Bu uygulama Devoplus One'ın **eğitim ve sertifika takibi** şablonundan
5
+ üretildi; ihtiyacınıza göre değiştirilecek bir başlangıç noktasıdır.
6
+
7
+ ## Ne yapar
8
+
9
+ 1. **Sertifikalarım:** herkes kendi eğitim ve sertifika kayıtlarını, geçerlilik sonlarını ve durumlarını (geçerli,
10
+ süresi doluyor, süresi doldu, yenilendi, süresiz) görür; belgeyi indirir, listeyi Excel'e aktarır.
11
+ 2. **Ekibim:** yöneticiler, organizasyon şemasında kendilerine bağlı çalışanların kayıtlarını görür ve Excel'e aktarır.
12
+ Ekip her açılışta organizasyon şemasından okunur; kimse başka bir ekibin kayıtlarını göremez.
13
+ 3. **Geçerlilik sonu kendiliğinden hesaplanır:** tamamlanma tarihi + sertifika türünün geçerlilik süresi (ay). Süresi
14
+ olmayan türler süresizdir. Aynı kişiye aynı türden daha yeni bir kayıt girilince eski kayıt "yenilendi" sayılır.
15
+ 4. **Hatırlatma:** her sabah 08:00'de, geçerliliği 30 gün içinde dolacak ya da dolmuş (yenilenmemiş) sertifikalar için
16
+ kişiye ve yöneticisine tek bir e-posta gider. Aynı sertifika her gün yeniden hatırlatılmaz; e-posta alıcılardan birine
17
+ ulaşmadıysa ertesi gün yalnız ona yeniden gönderilir.
18
+ 5. **Tüm kayıtlar ve sertifika türleri:** eğitim sorumlusu türleri tanımlar ve herkesin kayıtlarını girer (kişi, tür,
19
+ tamamlanma tarihi, belge numarası, belge dosyası). Liste süzülür ve Excel'e aktarılır.
20
+ 6. **Devoplus asistanı:** "Süresi yaklaşan sertifikalar hangileri?" gibi sorular Devoplus'un yapay zekâ asistanına
21
+ sorulduğunda cevap bu uygulamadan gelir (bu soru yalnız eğitim sorumlularına açıktır).
22
+
23
+ ## Kimler ne görür
24
+
25
+ | Kim | Ne yapabilir |
26
+ |---|---|
27
+ | Uygulamayı kullanma izni olan herkes ({{permissionCode}}) | Kendi kayıtlarını görür, belgesini indirir, Excel'e aktarır |
28
+ | Yönetici (organizasyon şemasına göre) | Ayrıca kendisine bağlı çalışanların kayıtlarını görür; hatırlatma e-postasını alır |
29
+ | Eğitim sorumlusu ({{permissionPrefix}}Manage) | Türleri ve bütün kayıtları yönetir; Tüm kayıtlar ve Sertifika türleri sekmelerini görür |
30
+
31
+ İzinleri portal yöneticisi **Yetki Yönetimi** ekranından verir. Yöneticilik ayrı bir izin değildir; organizasyon
32
+ şemasından gelir.
33
+
34
+ ## Neleri değiştirebilirsiniz
35
+
36
+ Yapay zekâ aracınıza iş diliyle söylemeniz yeterlidir:
37
+
38
+ - "Hatırlatma 30 değil 60 gün önce gitsin."
39
+ - "Ekibim görünümünde alt kademeler de görünsün" (bugün yalnız doğrudan bağlılar).
40
+ - "Eğitimi veren kurum ve eğitim saati de tutulsun" — yeni alan.
41
+ - "Çalışan kendi sertifikasını yükleyebilsin, eğitim sorumlusu onaylasın."
42
+
43
+ Bilinmesi gerekenler: sabah hatırlatması uygulamanın kendi adına gönderilir; bunun için portal yöneticisinin uygulamayı
44
+ kurarken **arka plan e-postasını onaylaması** gerekir. Onay yoksa hatırlatma gönderilmez, uygulamada "onay bekleniyor"
45
+ yazar. Hatırlatma, kayıt girilirken organizasyon şemasından alınan yöneticiye gider; yöneticiler değiştiyse eğitim
46
+ sorumlusu **Yöneticileri yenile** düğmesine basar. Bir sabahta en çok 5 kişi için e-posta gider; kalanlar ertesi sabaha kalır.